arcane-cli 0.17.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,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 dirname3, join as join11 } from "path";
9
+ import { dirname as dirname3, join as join12 } from "path";
10
10
 
11
11
  // src/commands/init.ts
12
- import { select as select2, confirm } from "@inquirer/prompts";
13
- import { join as join4 } from "path";
12
+ import { select as select2, confirm, input as input2 } from "@inquirer/prompts";
13
+ import { join as join5 } from "path";
14
14
  import chalk2 from "chalk";
15
15
 
16
16
  // src/modules/copier.ts
@@ -98,6 +98,20 @@ var ManifestInvalidFieldError = class extends Error {
98
98
  var MANIFEST_FILE = ".arcane.json";
99
99
  var VALID_TRACKING_MODES = ["internal", "external"];
100
100
  var VALID_EXTERNAL_PROVIDERS = ["ado", "jira", "other"];
101
+ var VALID_CONTENT_SENSITIVITY = ["standard", "sensitive"];
102
+ var VALID_PUSH_POLICIES = ["open", "guarded", "blocked"];
103
+ function isValidSubjectRoot(value) {
104
+ const invalid = value.trim() === "" || path2.isAbsolute(value) || // Windows drive-relative ("C:docs") and UNC-ish values also escape.
105
+ /^[a-zA-Z]:/.test(value) || value.startsWith("\\\\") || value.startsWith("\\") || value.split(/[\\/]/).includes("..") || // A stored subject_root is interpolated into agent prompts downstream, so
106
+ // keep it a single path-shaped token rather than a multi-line payload.
107
+ /[\r\n\0]/.test(value);
108
+ return !invalid;
109
+ }
110
+ function validateSubjectRoot(value, filePath) {
111
+ if (!isValidSubjectRoot(value)) {
112
+ throw new ManifestInvalidFieldError(filePath, "subject_root", value);
113
+ }
114
+ }
101
115
  function manifestPath(targetDir) {
102
116
  return path2.join(targetDir, MANIFEST_FILE);
103
117
  }
@@ -108,6 +122,22 @@ function validateTrackingFields(manifest, filePath) {
108
122
  if (manifest.external_provider !== void 0 && manifest.external_provider !== null && !VALID_EXTERNAL_PROVIDERS.includes(manifest.external_provider)) {
109
123
  throw new ManifestInvalidFieldError(filePath, "external_provider", manifest.external_provider);
110
124
  }
125
+ if (manifest.content_sensitivity !== void 0 && !VALID_CONTENT_SENSITIVITY.includes(manifest.content_sensitivity)) {
126
+ throw new ManifestInvalidFieldError(
127
+ filePath,
128
+ "content_sensitivity",
129
+ manifest.content_sensitivity
130
+ );
131
+ }
132
+ if (manifest.push_policy !== void 0 && !VALID_PUSH_POLICIES.includes(manifest.push_policy)) {
133
+ throw new ManifestInvalidFieldError(filePath, "push_policy", manifest.push_policy);
134
+ }
135
+ if (manifest.subject_root !== void 0 && manifest.subject_root !== null) {
136
+ if (typeof manifest.subject_root !== "string") {
137
+ throw new ManifestInvalidFieldError(filePath, "subject_root", manifest.subject_root);
138
+ }
139
+ validateSubjectRoot(manifest.subject_root, filePath);
140
+ }
111
141
  }
112
142
  async function readManifest(targetDir) {
113
143
  const filePath = manifestPath(targetDir);
@@ -155,15 +185,35 @@ var PROFILE_CONFIGS = [
155
185
  id: "lite",
156
186
  displayName: "Essentials",
157
187
  description: "Spells + core governance \u2014 fast start",
158
- components: ["spell-prompts", "claude-commands", "agent-output-instructions", "git-conventions", "testing-standards", "framework-decisions", "session-continuity"]
188
+ components: [
189
+ "spells-session",
190
+ "spells-capture",
191
+ "spells-delivery",
192
+ "spells-review",
193
+ "spells-planning",
194
+ "spells-build",
195
+ "spells-venture",
196
+ "spells-meta",
197
+ "agent-output-instructions",
198
+ "git-conventions",
199
+ "testing-standards",
200
+ "framework-decisions",
201
+ "session-continuity"
202
+ ]
159
203
  },
160
204
  {
161
205
  id: "methodology",
162
206
  displayName: "Methodology Suite",
163
207
  description: "Spells + governance \u2014 full methodology without security/infra docs",
164
208
  components: [
165
- "spell-prompts",
166
- "claude-commands",
209
+ "spells-session",
210
+ "spells-capture",
211
+ "spells-delivery",
212
+ "spells-review",
213
+ "spells-planning",
214
+ "spells-build",
215
+ "spells-venture",
216
+ "spells-meta",
167
217
  "agent-output-instructions",
168
218
  "git-conventions",
169
219
  "testing-standards",
@@ -175,6 +225,44 @@ var PROFILE_CONFIGS = [
175
225
  "spell-authoring-standards"
176
226
  ]
177
227
  },
228
+ {
229
+ id: "docs",
230
+ displayName: "Docs / Records",
231
+ description: "Documentation and records repositories \u2014 no code, test, or deployment workflows",
232
+ components: [
233
+ // Deliberate exclusions, per docs-mode PRD MH-01: "spells-build"
234
+ // (implementation, tests, stack experts, release, deployment, PRD
235
+ // enchantment, asset tooling), "spells-review" (adversarial CODE review
236
+ // -- its own workflow validates test coverage against source, which a
237
+ // docs repo has neither of), and "spells-venture" (hub-only).
238
+ //
239
+ // MH-01 requires every retained spell to complete its core workflow
240
+ // without source code, tests, CI, or an external tracker. Asserted --
241
+ // exclusions included, which MH-01 requires explicitly -- in
242
+ // test/docs-profile-registry-split.test.ts.
243
+ "spells-session",
244
+ "spells-capture",
245
+ "spells-delivery",
246
+ "spells-planning",
247
+ "spells-meta",
248
+ "spells-docs",
249
+ "docs-baseline",
250
+ "records-conventions",
251
+ "agent-output-instructions",
252
+ "git-conventions",
253
+ // Four retained docs spells link to agent-policies; it carries no code
254
+ // implication, so shipping it closes a dangling reference cheaply.
255
+ "agent-policies",
256
+ "framework-decisions",
257
+ "session-continuity",
258
+ "decision-documentation-standard",
259
+ "agent-work-queue-model",
260
+ "development-methodology",
261
+ "naming-conventions",
262
+ "universal-agent-rules",
263
+ "spell-authoring-standards"
264
+ ]
265
+ },
178
266
  {
179
267
  id: "governance-only",
180
268
  displayName: "Governance Only",
@@ -323,46 +411,159 @@ var COMPONENTS = [
323
411
  description: "Copilot enforcement rules: PR link format, merge strategy, and branch naming",
324
412
  files: [".github/instructions/agent-output.instructions.md"]
325
413
  },
326
- // Spell prompts — installed to .github/prompts/ (VS Code Copilot convention)
414
+ // Repository baseline files (EF-10 + EF-17). skipExisting: these are
415
+ // user-owned once written -- a repo with its own .gitattributes has an
416
+ // intentional policy Arcane must not overwrite. Sources are stored under a
417
+ // non-dotfile path and mapped via sourceOverrides; see RegistryComponent.
418
+ {
419
+ name: "docs-baseline",
420
+ description: "Line-ending and binary-format baseline for document repositories (.gitattributes/.gitignore)",
421
+ files: [".gitattributes", ".gitignore"],
422
+ sourceOverrides: {
423
+ ".gitattributes": "docs-baseline/gitattributes",
424
+ ".gitignore": "docs-baseline/gitignore"
425
+ },
426
+ skipExisting: true,
427
+ initOnly: true
428
+ },
429
+ {
430
+ name: "records-conventions",
431
+ description: "Supersession, tombstone, and retention conventions for records repositories",
432
+ files: [".arcane/governance/records-conventions.md"]
433
+ },
434
+ // ─── Spells ────────────────────────────────────────────────────────────────
435
+ // Split from the former monolithic `spell-prompts` + `claude-commands` pair
436
+ // so a profile can select spells by capability (see ARC-0NN). Grouping lives
437
+ // here and only here -- every spell file stays flat and unrenamed on disk.
438
+ //
439
+ // Each component carries BOTH client formats of the same spell: the Copilot
440
+ // prompt (.github/prompts/*.prompt.md) and the Claude Code wrapper
441
+ // (.claude/commands/*.md). They are never independently selectable -- every
442
+ // profile that wanted one always wanted the other -- and pairing them here
443
+ // makes it structurally impossible for the two formats of one spell to drift
444
+ // apart across profiles.
327
445
  {
328
- name: "spell-prompts",
329
- description: "Full Arcane spell prompt library (34 spells)",
446
+ name: "spells-session",
447
+ description: "Session lifecycle \u2014 open, close, commit, status, version",
330
448
  files: [
449
+ ".github/prompts/spell-open-session.prompt.md",
450
+ ".claude/commands/spell-open-session.md",
451
+ ".github/prompts/spell-close-session.prompt.md",
452
+ ".claude/commands/spell-close-session.md",
453
+ ".github/prompts/spell-commit-work.prompt.md",
454
+ ".claude/commands/spell-commit-work.md",
455
+ ".github/prompts/spell-status.prompt.md",
456
+ ".claude/commands/spell-status.md",
331
457
  ".github/prompts/spell-arcane-version.prompt.md",
332
- ".github/prompts/spell-architect.prompt.md",
458
+ ".claude/commands/spell-arcane-version.md"
459
+ ]
460
+ },
461
+ {
462
+ name: "spells-capture",
463
+ description: "Idea, todo, feedback and explanation capture",
464
+ files: [
465
+ ".github/prompts/spell-save-idea.prompt.md",
466
+ ".claude/commands/spell-save-idea.md",
467
+ ".github/prompts/spell-todo.prompt.md",
468
+ ".claude/commands/spell-todo.md",
469
+ ".github/prompts/spell-feedback.prompt.md",
470
+ ".claude/commands/spell-feedback.md",
471
+ ".github/prompts/spell-suggest-feature.prompt.md",
472
+ ".claude/commands/spell-suggest-feature.md",
473
+ ".github/prompts/spell-document.prompt.md",
474
+ ".claude/commands/spell-document.md",
333
475
  ".github/prompts/spell-brainstorm.prompt.md",
476
+ ".claude/commands/spell-brainstorm.md",
477
+ ".github/prompts/spell-explain-concept.prompt.md",
478
+ ".claude/commands/spell-explain-concept.md"
479
+ ]
480
+ },
481
+ {
482
+ name: "spells-delivery",
483
+ description: "Pull-request lifecycle \u2014 open a PR, respond to review feedback",
484
+ files: [
485
+ ".github/prompts/spell-create-pull-request.prompt.md",
486
+ ".claude/commands/spell-create-pull-request.md",
487
+ ".github/prompts/spell-address-review.prompt.md",
488
+ ".claude/commands/spell-address-review.md"
489
+ ]
490
+ },
491
+ {
492
+ name: "spells-review",
493
+ description: "Adversarial code review \u2014 requires source and tests, so excluded from docs-only profiles",
494
+ files: [
495
+ ".github/prompts/spell-review.prompt.md",
496
+ ".claude/commands/spell-review.md",
497
+ ".github/prompts/spell-review-batch.prompt.md",
498
+ ".claude/commands/spell-review-batch.md"
499
+ ]
500
+ },
501
+ {
502
+ name: "spells-planning",
503
+ description: "PRD, architecture, scoping and product review",
504
+ files: [
505
+ ".github/prompts/spell-plan.prompt.md",
506
+ ".claude/commands/spell-plan.md",
507
+ ".github/prompts/spell-architect.prompt.md",
508
+ ".claude/commands/spell-architect.md",
334
509
  ".github/prompts/spell-scope.prompt.md",
335
- ".github/prompts/spell-summon-venture.prompt.md",
510
+ ".claude/commands/spell-scope.md",
511
+ ".github/prompts/spell-product-review.prompt.md",
512
+ ".claude/commands/spell-product-review.md"
513
+ ]
514
+ },
515
+ {
516
+ name: "spells-build",
517
+ description: "Code and product delivery \u2014 implementation, tests, stack experts, release, deployment, asset tooling",
518
+ files: [
519
+ ".github/prompts/spell-implement.prompt.md",
520
+ ".claude/commands/spell-implement.md",
521
+ ".github/prompts/spell-test.prompt.md",
522
+ ".claude/commands/spell-test.md",
523
+ ".github/prompts/spell-full-cycle.prompt.md",
524
+ ".claude/commands/spell-full-cycle.md",
336
525
  ".github/prompts/spell-bug.prompt.md",
526
+ ".claude/commands/spell-bug.md",
337
527
  ".github/prompts/spell-bump.prompt.md",
338
- ".github/prompts/spell-check-drift.prompt.md",
339
- ".github/prompts/spell-close-session.prompt.md",
340
- ".github/prompts/spell-commit-work.prompt.md",
528
+ ".claude/commands/spell-bump.md",
341
529
  ".github/prompts/spell-dotnet-expert.prompt.md",
342
- ".github/prompts/spell-enchant.prompt.md",
343
- ".github/prompts/spell-explain-concept.prompt.md",
344
- ".github/prompts/spell-full-cycle.prompt.md",
345
- ".github/prompts/spell-generate-bot-icons.prompt.md",
346
- ".github/prompts/spell-implement.prompt.md",
347
- ".github/prompts/spell-open-session.prompt.md",
348
- ".github/prompts/spell-plan.prompt.md",
349
- ".github/prompts/spell-present-arcane.prompt.md",
350
- ".github/prompts/spell-product-review.prompt.md",
351
- ".github/prompts/spell-review.prompt.md",
530
+ ".claude/commands/spell-dotnet-expert.md",
352
531
  ".github/prompts/spell-security-review.prompt.md",
532
+ ".claude/commands/spell-security-review.md",
353
533
  ".github/prompts/spell-ship.prompt.md",
354
- ".github/prompts/spell-suggest-feature.prompt.md",
355
- ".github/prompts/spell-test.prompt.md",
356
- ".github/prompts/spell-todo.prompt.md",
357
- // Workflow spell additions — PR lifecycle, status, capture, feedback
358
- ".github/prompts/spell-address-review.prompt.md",
359
- ".github/prompts/spell-create-pull-request.prompt.md",
360
- ".github/prompts/spell-document.prompt.md",
361
- ".github/prompts/spell-feedback.prompt.md",
534
+ ".claude/commands/spell-ship.md",
535
+ ".github/prompts/spell-enchant.prompt.md",
536
+ ".claude/commands/spell-enchant.md",
537
+ ".github/prompts/spell-generate-bot-icons.prompt.md",
538
+ ".claude/commands/spell-generate-bot-icons.md"
539
+ ]
540
+ },
541
+ {
542
+ name: "spells-docs",
543
+ description: "Documentation and records workflows \u2014 adopt an existing document tree",
544
+ files: [
545
+ ".github/prompts/spell-adopt-docs.prompt.md",
546
+ ".claude/commands/spell-adopt-docs.md"
547
+ ]
548
+ },
549
+ {
550
+ name: "spells-venture",
551
+ description: "Hub-only venture management",
552
+ files: [
553
+ ".github/prompts/spell-summon-venture.prompt.md",
554
+ ".claude/commands/spell-summon-venture.md",
362
555
  ".github/prompts/spell-manifest.prompt.md",
363
- ".github/prompts/spell-save-idea.prompt.md",
364
- ".github/prompts/spell-status.prompt.md",
365
- ".github/prompts/spell-review-batch.prompt.md"
556
+ ".claude/commands/spell-manifest.md"
557
+ ]
558
+ },
559
+ {
560
+ name: "spells-meta",
561
+ description: "Arcane-about-Arcane \u2014 presentation and documentation drift detection",
562
+ files: [
563
+ ".github/prompts/spell-present-arcane.prompt.md",
564
+ ".claude/commands/spell-present-arcane.md",
565
+ ".github/prompts/spell-check-drift.prompt.md",
566
+ ".claude/commands/spell-check-drift.md"
366
567
  ]
367
568
  },
368
569
  // Templates
@@ -396,53 +597,11 @@ var COMPONENTS = [
396
597
  name: "agent-definitions",
397
598
  description: "Agent policy reference template (for governance-only setups \u2014 use `spell agents init` for the full agent system)",
398
599
  files: [".arcane/agents/agent-policies-template.md"]
399
- },
600
+ }
400
601
  // NOTE: the static `agent-files` component was retired — `.github/agents/*.agent.md`
401
602
  // files are generated per-roster by `spell agents init` / `spell agents sync`, so
402
603
  // installing a fixed-name set via `spell init` shipped stale names and collided
403
604
  // with the generated output.
404
- // Claude commands
405
- {
406
- name: "claude-commands",
407
- description: "Claude Code slash command wrappers for all 34 Arcane spells",
408
- files: [
409
- ".claude/commands/spell-arcane-version.md",
410
- ".claude/commands/spell-architect.md",
411
- ".claude/commands/spell-brainstorm.md",
412
- ".claude/commands/spell-scope.md",
413
- ".claude/commands/spell-summon-venture.md",
414
- ".claude/commands/spell-bug.md",
415
- ".claude/commands/spell-bump.md",
416
- ".claude/commands/spell-check-drift.md",
417
- ".claude/commands/spell-close-session.md",
418
- ".claude/commands/spell-commit-work.md",
419
- ".claude/commands/spell-dotnet-expert.md",
420
- ".claude/commands/spell-enchant.md",
421
- ".claude/commands/spell-explain-concept.md",
422
- ".claude/commands/spell-full-cycle.md",
423
- ".claude/commands/spell-generate-bot-icons.md",
424
- ".claude/commands/spell-implement.md",
425
- ".claude/commands/spell-open-session.md",
426
- ".claude/commands/spell-plan.md",
427
- ".claude/commands/spell-present-arcane.md",
428
- ".claude/commands/spell-product-review.md",
429
- ".claude/commands/spell-review.md",
430
- ".claude/commands/spell-security-review.md",
431
- ".claude/commands/spell-ship.md",
432
- ".claude/commands/spell-suggest-feature.md",
433
- ".claude/commands/spell-test.md",
434
- ".claude/commands/spell-todo.md",
435
- // Workflow spell additions — PR lifecycle, status, capture, feedback
436
- ".claude/commands/spell-address-review.md",
437
- ".claude/commands/spell-create-pull-request.md",
438
- ".claude/commands/spell-document.md",
439
- ".claude/commands/spell-feedback.md",
440
- ".claude/commands/spell-manifest.md",
441
- ".claude/commands/spell-save-idea.md",
442
- ".claude/commands/spell-status.md",
443
- ".claude/commands/spell-review-batch.md"
444
- ]
445
- }
446
605
  ];
447
606
  function buildProfileMap() {
448
607
  const allNames = COMPONENTS.map((c) => c.name);
@@ -454,6 +613,23 @@ function buildProfileMap() {
454
613
  }
455
614
  var PROFILE_MAP = buildProfileMap();
456
615
  var componentIndex = new Map(COMPONENTS.map((c) => [c.name, c]));
616
+ var SPELL_COMPONENT_NAMES = COMPONENTS.map((c) => c.name).filter(
617
+ (n) => n.startsWith("spells-")
618
+ );
619
+ var LEGACY_MONOLITH_REPLACEMENTS = [
620
+ "spells-session",
621
+ "spells-capture",
622
+ "spells-delivery",
623
+ "spells-review",
624
+ "spells-planning",
625
+ "spells-build",
626
+ "spells-venture",
627
+ "spells-meta"
628
+ ];
629
+ var LEGACY_COMPONENT_MIGRATIONS = Object.assign(/* @__PURE__ */ Object.create(null), {
630
+ "spell-prompts": LEGACY_MONOLITH_REPLACEMENTS,
631
+ "claude-commands": LEGACY_MONOLITH_REPLACEMENTS
632
+ });
457
633
  function getComponent(name) {
458
634
  const component = componentIndex.get(name);
459
635
  if (!component) {
@@ -496,7 +672,7 @@ var WORDMARK = [
496
672
  var INDENT = " ";
497
673
  var LOCKUP_WIDTH = INDENT.length + Math.max(...WORDMARK.map((l) => l.length));
498
674
  function sleep(ms) {
499
- return new Promise((resolve) => setTimeout(resolve, ms));
675
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
500
676
  }
501
677
  function isAnimatable() {
502
678
  return !!process.stdout.isTTY && process.env["TERM"] !== "dumb" && process.env["CI"] !== "true" && process.env["ARCANE_DISABLE_ANIMATION"] !== "true" && (process.stdout.columns ?? 80) >= LOCKUP_WIDTH;
@@ -1618,6 +1794,10 @@ async function runAgentsList(targetDir) {
1618
1794
  console.log();
1619
1795
  }
1620
1796
 
1797
+ // src/modules/push-safety.ts
1798
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod } from "fs/promises";
1799
+ import { join as join4 } from "path";
1800
+
1621
1801
  // src/modules/git.ts
1622
1802
  import { execFile } from "child_process";
1623
1803
  var GitTimeoutError = class extends Error {
@@ -1713,7 +1893,7 @@ function buildGitEnv() {
1713
1893
  function runGit(cwd, args, options = {}) {
1714
1894
  const commandClass = options.commandClass ?? classifyGitCommand(args);
1715
1895
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
1716
- return new Promise((resolve, reject) => {
1896
+ return new Promise((resolve2, reject) => {
1717
1897
  const child = execFile(
1718
1898
  "git",
1719
1899
  args,
@@ -1747,7 +1927,7 @@ function runGit(cwd, args, options = {}) {
1747
1927
  reject(error);
1748
1928
  return;
1749
1929
  }
1750
- resolve({ stdout, stderr });
1930
+ resolve2({ stdout, stderr });
1751
1931
  }
1752
1932
  );
1753
1933
  child.stdin?.end();
@@ -1836,28 +2016,169 @@ async function ensureLocalPullRebase(cwd) {
1836
2016
  }
1837
2017
  }
1838
2018
 
2019
+ // src/modules/push-safety.ts
2020
+ var DISABLED_PUSH_URL = "arcane-push-blocked://this-repository-is-push-blocked";
2021
+ var ARCANE_HOOKS_DIR = ".arcane/hooks";
2022
+ async function readHooksPath(cwd) {
2023
+ let effective;
2024
+ try {
2025
+ const { stdout } = await runGit(cwd, ["config", "--get", "core.hooksPath"]);
2026
+ effective = stdout.trim();
2027
+ } catch {
2028
+ return void 0;
2029
+ }
2030
+ if (effective === "") return void 0;
2031
+ let local = "";
2032
+ try {
2033
+ const { stdout } = await runGit(cwd, ["config", "--local", "--get", "core.hooksPath"]);
2034
+ local = stdout.trim();
2035
+ } catch {
2036
+ }
2037
+ return { value: effective, scope: local === effective && local !== "" ? "local" : "inherited" };
2038
+ }
2039
+ var HOOK_BODY = `#!/bin/sh
2040
+ # Installed by Arcane because this repository's .arcane.json sets
2041
+ # push_policy: "blocked".
2042
+ #
2043
+ # This is deliberately not tamper-proof. It guards against an accidental push
2044
+ # -- the wrong remote, muscle memory, an unsupervised agent -- not against a
2045
+ # determined operator, who can always bypass it.
2046
+ #
2047
+ # To push legitimately, undo the policy first:
2048
+ #
2049
+ # spell unblock-push
2050
+ #
2051
+ # That command is interactive-only and asks you to confirm the repository by
2052
+ # name. Do not work around this hook by other means: the manifest would then
2053
+ # claim a protection this repository no longer has.
2054
+ echo "arcane: push blocked (push_policy: blocked). Run 'spell unblock-push' to undo." >&2
2055
+ exit 1
2056
+ `;
2057
+ function hookFilePath(cwd) {
2058
+ return join4(cwd, ARCANE_HOOKS_DIR, "pre-push");
2059
+ }
2060
+ async function installPrePushHook(cwd) {
2061
+ const existing = await readHooksPath(cwd);
2062
+ if (existing !== void 0 && existing.value !== ARCANE_HOOKS_DIR) {
2063
+ return {
2064
+ status: "refused-foreign-hooks-path",
2065
+ existing: existing.value,
2066
+ scope: existing.scope
2067
+ };
2068
+ }
2069
+ const hookPath = hookFilePath(cwd);
2070
+ if (existing?.value === ARCANE_HOOKS_DIR && await fileExists(hookPath)) {
2071
+ const current = await readFile4(hookPath, "utf-8");
2072
+ if (current === HOOK_BODY) return { status: "already-ours" };
2073
+ }
2074
+ await mkdir4(join4(cwd, ARCANE_HOOKS_DIR), { recursive: true });
2075
+ await writeFile5(hookPath, HOOK_BODY, "utf-8");
2076
+ await chmod(hookPath, 493);
2077
+ await runGit(cwd, ["config", "--local", "core.hooksPath", ARCANE_HOOKS_DIR]);
2078
+ return { status: "installed", path: hookPath };
2079
+ }
2080
+ async function isHookEnforced(cwd) {
2081
+ const hooksPath = await readHooksPath(cwd);
2082
+ if (hooksPath?.value !== ARCANE_HOOKS_DIR) return false;
2083
+ return fileExists(hookFilePath(cwd));
2084
+ }
2085
+ async function removePrePushHook(cwd) {
2086
+ const existing = await readHooksPath(cwd);
2087
+ if (existing?.value !== ARCANE_HOOKS_DIR || existing.scope !== "local") return;
2088
+ try {
2089
+ await runGit(cwd, ["config", "--local", "--unset", "core.hooksPath"]);
2090
+ } catch {
2091
+ }
2092
+ }
2093
+ async function listRemotes(cwd) {
2094
+ try {
2095
+ const { stdout } = await runGit(cwd, ["remote"]);
2096
+ return stdout.split("\n").map((r) => r.trim()).filter(Boolean);
2097
+ } catch {
2098
+ return [];
2099
+ }
2100
+ }
2101
+ async function disablePushUrls(cwd) {
2102
+ const results = [];
2103
+ for (const remote of await listRemotes(cwd)) {
2104
+ let current;
2105
+ try {
2106
+ const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", remote]);
2107
+ current = stdout.trim();
2108
+ } catch {
2109
+ continue;
2110
+ }
2111
+ if (current === DISABLED_PUSH_URL) {
2112
+ results.push({ remote, status: "already-disabled" });
2113
+ continue;
2114
+ }
2115
+ await runGit(cwd, ["config", "--local", `arcane.originalPushUrl.${remote}`, current]);
2116
+ await runGit(cwd, ["remote", "set-url", "--push", remote, DISABLED_PUSH_URL]);
2117
+ results.push({ remote, status: "disabled" });
2118
+ }
2119
+ return results;
2120
+ }
2121
+ async function undisabledRemotes(cwd) {
2122
+ const open = [];
2123
+ for (const remote of await listRemotes(cwd)) {
2124
+ try {
2125
+ const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", remote]);
2126
+ if (stdout.trim() !== DISABLED_PUSH_URL) open.push(remote);
2127
+ } catch {
2128
+ open.push(remote);
2129
+ }
2130
+ }
2131
+ return open;
2132
+ }
2133
+ async function restorePushUrls(cwd) {
2134
+ const restored = [];
2135
+ for (const remote of await listRemotes(cwd)) {
2136
+ let original = "";
2137
+ try {
2138
+ const { stdout } = await runGit(cwd, [
2139
+ "config",
2140
+ "--local",
2141
+ "--get",
2142
+ `arcane.originalPushUrl.${remote}`
2143
+ ]);
2144
+ original = stdout.trim();
2145
+ } catch {
2146
+ continue;
2147
+ }
2148
+ if (original === "") continue;
2149
+ await runGit(cwd, ["remote", "set-url", "--push", remote, original]);
2150
+ try {
2151
+ await runGit(cwd, ["config", "--local", "--unset", `arcane.originalPushUrl.${remote}`]);
2152
+ } catch {
2153
+ }
2154
+ restored.push(remote);
2155
+ }
2156
+ return restored;
2157
+ }
2158
+
1839
2159
  // src/commands/init.ts
1840
- var VALID_PROFILES = ["full", "lite", "governance-only", "methodology"];
2160
+ var VALID_PROFILES = ["full", "lite", "governance-only", "methodology", "docs"];
2161
+ var PROFILE_ICONS = {
2162
+ full: "\u{1F9D9}",
2163
+ lite: "\u26A1",
2164
+ docs: "\u{1F4D6}"
2165
+ };
2166
+ function isSpellComponent(name) {
2167
+ return name.startsWith("spells-");
2168
+ }
1841
2169
  function categorizeComponents(components) {
1842
2170
  const groups = [];
1843
- const prompts = components.find((c) => c.name === "spell-prompts");
1844
- if (prompts) {
1845
- groups.push({
1846
- icon: "\u2728",
1847
- label: "Copilot Spells",
1848
- count: prompts.files.length
1849
- });
2171
+ const spellFiles = components.filter((c) => isSpellComponent(c.name)).flatMap((c) => c.files);
2172
+ const promptCount = spellFiles.filter((f) => f.startsWith(".github/prompts/")).length;
2173
+ if (promptCount > 0) {
2174
+ groups.push({ icon: "\u2728", label: "Copilot Spells", count: promptCount });
1850
2175
  }
1851
- const claude = components.find((c) => c.name === "claude-commands");
1852
- if (claude) {
1853
- groups.push({
1854
- icon: "\u26A1",
1855
- label: "Claude Spells",
1856
- count: claude.files.length
1857
- });
2176
+ const claudeCount = spellFiles.filter((f) => f.startsWith(".claude/commands/")).length;
2177
+ if (claudeCount > 0) {
2178
+ groups.push({ icon: "\u26A1", label: "Claude Spells", count: claudeCount });
1858
2179
  }
1859
2180
  const governance = components.filter(
1860
- (c) => c.name !== "spell-prompts" && c.name !== "claude-commands" && c.name !== "venture-template" && c.name !== "agent-definitions" && c.files.some((f) => f.startsWith(".arcane/governance/"))
2181
+ (c) => !isSpellComponent(c.name) && c.name !== "venture-template" && c.name !== "agent-definitions" && c.files.some((f) => f.startsWith(".arcane/governance/"))
1861
2182
  );
1862
2183
  if (governance.length > 0) {
1863
2184
  groups.push({
@@ -1963,7 +2284,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
1963
2284
  const profileComponents = getProfile(p.id);
1964
2285
  return {
1965
2286
  value: p.id,
1966
- name: `${p.id === "full" ? "\u{1F9D9}" : p.id === "lite" ? "\u26A1" : "\u{1F4CB}"} ${p.displayName} \u2014 ${p.description}`,
2287
+ name: `${PROFILE_ICONS[p.id] ?? "\u{1F4CB}"} ${p.displayName} \u2014 ${p.description}`,
1967
2288
  description: buildProfileDescription(profileComponents, p.id)
1968
2289
  };
1969
2290
  })
@@ -1997,7 +2318,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
1997
2318
  for (const component of components) {
1998
2319
  const installedFiles = [];
1999
2320
  for (const file of component.files) {
2000
- const srcPath = join4(assetsDir, file);
2321
+ const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
2001
2322
  if (options.dryRun) {
2002
2323
  printDryRun(`Would copy: ${file}`);
2003
2324
  } else {
@@ -2014,7 +2335,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2014
2335
  fileCount++;
2015
2336
  }
2016
2337
  for (const dir of component.directories ?? []) {
2017
- const srcDirPath = join4(assetsDir, dir);
2338
+ const srcDirPath = join5(assetsDir, dir);
2018
2339
  if (options.dryRun) {
2019
2340
  printDryRun(`Would copy directory: ${dir}/`);
2020
2341
  } else {
@@ -2039,7 +2360,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2039
2360
  if (options.dryRun) {
2040
2361
  if (hasAgents) {
2041
2362
  const agentFiles = await import("fs").then(
2042
- (fsModule) => fsModule.promises.readdir(join4(assetsDir, "agents"))
2363
+ (fsModule) => fsModule.promises.readdir(join5(assetsDir, "agents"))
2043
2364
  );
2044
2365
  const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
2045
2366
  printDryRun("Would run agent setup (interactive):");
@@ -2062,7 +2383,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2062
2383
  }
2063
2384
  let tracking_mode;
2064
2385
  let external_provider;
2065
- if (profile === "governance-only" || profile === "methodology") {
2386
+ if (profile === "governance-only" || profile === "methodology" || profile === "docs") {
2066
2387
  tracking_mode = "internal";
2067
2388
  external_provider = null;
2068
2389
  } else if (!options.profile) {
@@ -2087,16 +2408,110 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2087
2408
  external_provider = null;
2088
2409
  }
2089
2410
  }
2411
+ let subject_root;
2412
+ if (profile === "docs" && !options.profile) {
2413
+ console.log();
2414
+ const shape = await select2({
2415
+ message: "What does this repository hold?",
2416
+ choices: [
2417
+ {
2418
+ value: "root",
2419
+ name: "One subject, at the repository root (documents sit alongside Arcane's files)"
2420
+ },
2421
+ { value: "subdir", name: "One subject, in its own directory" },
2422
+ { value: "portfolio", name: "Several subjects or ventures (decide per-document later)" }
2423
+ ]
2424
+ });
2425
+ if (shape === "root") {
2426
+ subject_root = ".";
2427
+ } else if (shape === "subdir") {
2428
+ const answer = await input2({
2429
+ message: "Directory holding the subject's documents:",
2430
+ default: "docs",
2431
+ validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
2432
+ });
2433
+ subject_root = answer.trim() || "docs";
2434
+ } else if (shape === "portfolio") {
2435
+ subject_root = null;
2436
+ }
2437
+ }
2438
+ let content_sensitivity;
2439
+ if (!options.profile) {
2440
+ console.log();
2441
+ content_sensitivity = await select2({
2442
+ message: "How should agents treat this repository's contents?",
2443
+ choices: [
2444
+ { value: "standard", name: "Standard \u2014 agents may quote contents in journals and decisions" },
2445
+ {
2446
+ value: "sensitive",
2447
+ name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
2448
+ }
2449
+ ],
2450
+ default: "standard"
2451
+ });
2452
+ }
2453
+ let push_policy;
2454
+ if (!options.profile) {
2455
+ console.log();
2456
+ push_policy = await select2({
2457
+ message: "Should this repository be allowed to push to a remote?",
2458
+ choices: [
2459
+ { value: "open", name: "Yes \u2014 normal repository" },
2460
+ { value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
2461
+ {
2462
+ value: "blocked",
2463
+ name: "No \u2014 block pushes (a pre-push hook plus a disabled push URL)"
2464
+ }
2465
+ ],
2466
+ default: "open"
2467
+ });
2468
+ }
2090
2469
  const manifest = {
2091
2470
  version: packageVersion,
2092
2471
  profile,
2093
2472
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2094
2473
  components: installedComponents,
2095
2474
  ...role ? { role } : {},
2096
- ...tracking_mode ? { tracking_mode, external_provider } : {}
2475
+ ...tracking_mode ? { tracking_mode, external_provider } : {},
2476
+ ...subject_root !== void 0 ? { subject_root } : {},
2477
+ ...content_sensitivity ? { content_sensitivity } : {},
2478
+ ...push_policy ? { push_policy } : {}
2097
2479
  };
2098
2480
  await writeManifest(targetDir, manifest);
2099
2481
  printSuccess(`Initialized with profile "${profile}" \u2014 ${fileCount} files installed`);
2482
+ if (push_policy === "blocked" || push_policy === "guarded") {
2483
+ try {
2484
+ if (push_policy === "blocked") {
2485
+ const hook = await installPrePushHook(targetDir);
2486
+ if (hook.status === "refused-foreign-hooks-path") {
2487
+ printWarning(
2488
+ `Did not install the pre-push hook: core.hooksPath is already "${hook.existing}" (set ${hook.scope === "local" ? "for this repository" : "globally"}), so another hook manager owns it. Overwriting would silently disable those hooks. Chain an Arcane pre-push guard into that directory yourself, or unset core.hooksPath first.`
2489
+ );
2490
+ } else {
2491
+ printInfo("Installed a pre-push hook that blocks pushes from this repository.");
2492
+ }
2493
+ const urls = await disablePushUrls(targetDir);
2494
+ if (urls.length === 0) {
2495
+ printWarning(
2496
+ "No remote is configured, so only the pre-push hook is active. A remote added later will NOT have its push URL disabled automatically, and a `--no-verify` push to it would succeed. Run `spell doctor` after adding one \u2014 it reports this gap."
2497
+ );
2498
+ } else {
2499
+ printInfo(
2500
+ `Disabled the push URL for: ${urls.map((u) => u.remote).join(", ")} (fetch still works).`
2501
+ );
2502
+ }
2503
+ printInfo("Run `spell unblock-push` from a terminal to undo this.");
2504
+ } else {
2505
+ printInfo(
2506
+ "Marked as push-guarded. No technical control was installed \u2014 check the remote is the one you intend before pushing. `spell doctor` will keep reminding you."
2507
+ );
2508
+ }
2509
+ } catch (err) {
2510
+ printWarning(
2511
+ `Could not fully apply push-safety controls: ${err instanceof Error ? err.message : String(err)}. The manifest records push_policy: "${push_policy}", but this repository may not actually be protected. Run \`spell doctor\` to see exactly what is and isn't in place.`
2512
+ );
2513
+ }
2514
+ }
2100
2515
  let agentsConfigured = false;
2101
2516
  if (hasAgents && !options.profile) {
2102
2517
  console.log();
@@ -2142,7 +2557,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2142
2557
  }
2143
2558
 
2144
2559
  // src/commands/add.ts
2145
- import { join as join5 } from "path";
2560
+ import { join as join6 } from "path";
2146
2561
  async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2147
2562
  let manifest;
2148
2563
  try {
@@ -2177,7 +2592,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2177
2592
  let fileCount = 0;
2178
2593
  const installedFiles = [];
2179
2594
  for (const file of component.files) {
2180
- const srcPath = join5(assetsDir, file);
2595
+ const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
2181
2596
  if (options.dryRun) {
2182
2597
  console.log(` [dry-run] Would copy: ${file}`);
2183
2598
  } else {
@@ -2187,7 +2602,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2187
2602
  fileCount++;
2188
2603
  }
2189
2604
  for (const dir of component.directories ?? []) {
2190
- const srcDirPath = join5(assetsDir, dir);
2605
+ const srcDirPath = join6(assetsDir, dir);
2191
2606
  if (options.dryRun) {
2192
2607
  console.log(` [dry-run] Would copy directory: ${dir}/`);
2193
2608
  } else {
@@ -2217,12 +2632,12 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2217
2632
  }
2218
2633
 
2219
2634
  // src/commands/update.ts
2220
- import { join as join7 } from "path";
2635
+ import { join as join8 } from "path";
2221
2636
 
2222
2637
  // src/modules/hub.ts
2223
- import { confirm as confirm2, select as select3 } from "@inquirer/prompts";
2224
- import { readdir as readdir3, writeFile as writeFile5 } from "fs/promises";
2225
- import { join as join6 } from "path";
2638
+ import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
2639
+ import { readdir as readdir3, writeFile as writeFile6 } from "fs/promises";
2640
+ import { join as join7 } from "path";
2226
2641
  var MANIFEST_RETROFITS = [
2227
2642
  {
2228
2643
  field: "role",
@@ -2241,7 +2656,7 @@ var MANIFEST_RETROFITS = [
2241
2656
  // Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
2242
2657
  // profiles get a silent default, full/lite get asked.
2243
2658
  ask: async (manifest) => {
2244
- if (manifest.profile === "governance-only" || manifest.profile === "methodology") {
2659
+ if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
2245
2660
  return { tracking_mode: "internal", external_provider: null };
2246
2661
  }
2247
2662
  const tracking_mode = await select3({
@@ -2264,6 +2679,85 @@ var MANIFEST_RETROFITS = [
2264
2679
  }
2265
2680
  return { tracking_mode, external_provider: null };
2266
2681
  }
2682
+ },
2683
+ {
2684
+ field: "content_sensitivity",
2685
+ needsRetrofit: (m) => m.content_sensitivity === void 0,
2686
+ // EF-12. Asked for every profile -- a code repo can hold sensitive records
2687
+ // too -- and defaults to "standard", so an operator who just presses enter
2688
+ // keeps today's behaviour exactly.
2689
+ ask: async () => {
2690
+ const content_sensitivity = await select3({
2691
+ message: "How should agents treat this repository's contents?",
2692
+ choices: [
2693
+ {
2694
+ value: "standard",
2695
+ name: "Standard \u2014 agents may quote contents in journals and decisions"
2696
+ },
2697
+ {
2698
+ value: "sensitive",
2699
+ name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
2700
+ }
2701
+ ],
2702
+ default: "standard"
2703
+ });
2704
+ return { content_sensitivity };
2705
+ }
2706
+ },
2707
+ {
2708
+ field: "subject_root",
2709
+ // EF-07. Only the docs profile is asked: other profiles describe code or a
2710
+ // venture portfolio, where "what is this repo about" is already answered.
2711
+ // A docs install that legitimately holds several subjects answers
2712
+ // "portfolio" and stays unset -- so this retrofit deliberately does NOT
2713
+ // re-ask on every update. It is gated on the field being absent AND the
2714
+ // profile being docs, and once answered (either way) it never fires again,
2715
+ // because "portfolio" writes an explicit empty marker.
2716
+ needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
2717
+ ask: async () => {
2718
+ const shape = await select3({
2719
+ message: "What does this repository hold?",
2720
+ choices: [
2721
+ {
2722
+ value: "root",
2723
+ name: "One subject, at the repository root (documents sit alongside Arcane's files)"
2724
+ },
2725
+ { value: "subdir", name: "One subject, in its own directory" },
2726
+ { value: "portfolio", name: "Several subjects or ventures" }
2727
+ ]
2728
+ });
2729
+ if (shape === "root") return { subject_root: "." };
2730
+ if (shape === "subdir") {
2731
+ const answer = await input3({
2732
+ message: "Directory holding the subject's documents:",
2733
+ default: "docs",
2734
+ validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
2735
+ });
2736
+ return { subject_root: answer.trim() || "docs" };
2737
+ }
2738
+ return { subject_root: null };
2739
+ }
2740
+ },
2741
+ {
2742
+ field: "push_policy",
2743
+ needsRetrofit: (m) => m.push_policy === void 0,
2744
+ // EF-09. Defaults to "open", so an operator who just presses enter keeps
2745
+ // today's behaviour exactly. NOTE: this only records the choice -- unlike
2746
+ // init, the retrofit does not install the hook or disable the push URL,
2747
+ // because an update should not silently start blocking pushes in a repo
2748
+ // someone is mid-workflow in. doctor reports the gap instead.
2749
+ ask: async () => {
2750
+ const push_policy = await select3({
2751
+ message: "Should this repository be allowed to push to a remote?",
2752
+ choices: [
2753
+ { value: "open", name: "Yes \u2014 normal repository" },
2754
+ { value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
2755
+ { value: "blocked", name: "No \u2014 block pushes" }
2756
+ ],
2757
+ default: "open"
2758
+ });
2759
+ return { push_policy };
2760
+ }
2267
2761
  }
2268
2762
  ];
2269
2763
  async function runManifestRetrofits(manifest) {
@@ -2281,9 +2775,9 @@ async function runManifestRetrofits(manifest) {
2281
2775
  return patch;
2282
2776
  }
2283
2777
  async function offerRegistryScaffold(targetDir, businessRoot) {
2284
- const registryPath = join6(targetDir, businessRoot, "registry.json");
2778
+ const registryPath = join7(targetDir, businessRoot, "registry.json");
2285
2779
  if (await fileExists(registryPath)) return;
2286
- const businessRootPath = join6(targetDir, businessRoot);
2780
+ const businessRootPath = join7(targetDir, businessRoot);
2287
2781
  let ventureDirs;
2288
2782
  try {
2289
2783
  const entries = await readdir3(businessRootPath, { withFileTypes: true });
@@ -2321,13 +2815,32 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
2321
2815
  updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
2322
2816
  ventures
2323
2817
  };
2324
- await writeFile5(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
2818
+ await writeFile6(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
2325
2819
  console.log(
2326
2820
  ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
2327
2821
  );
2328
2822
  }
2329
2823
 
2330
2824
  // src/commands/update.ts
2825
+ function migrateLegacyComponents(components) {
2826
+ const out = [];
2827
+ const seen = /* @__PURE__ */ new Set();
2828
+ for (const entry of components) {
2829
+ const replacements = LEGACY_COMPONENT_MIGRATIONS[entry.name];
2830
+ if (!replacements) {
2831
+ if (seen.has(entry.name)) continue;
2832
+ seen.add(entry.name);
2833
+ out.push(entry);
2834
+ continue;
2835
+ }
2836
+ for (const name of replacements) {
2837
+ if (seen.has(name)) continue;
2838
+ seen.add(name);
2839
+ out.push({ name, files: [], installedVersion: entry.installedVersion });
2840
+ }
2841
+ }
2842
+ return out;
2843
+ }
2331
2844
  async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2332
2845
  let manifest;
2333
2846
  try {
@@ -2380,7 +2893,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2380
2893
  }
2381
2894
  let fileCount = 0;
2382
2895
  const updatedComponents = [];
2383
- for (const installed of manifest.components) {
2896
+ const componentsToUpdate = migrateLegacyComponents(manifest.components);
2897
+ for (const installed of componentsToUpdate) {
2384
2898
  let component;
2385
2899
  try {
2386
2900
  component = getComponent(installed.name);
@@ -2394,8 +2908,15 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2394
2908
  }
2395
2909
  const updatedFiles = [];
2396
2910
  for (const file of component.files) {
2397
- const srcPath = join7(assetsDir, file);
2398
- const preserveExisting = component.skipExisting && await fileExists(join7(targetDir, file));
2911
+ const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
2912
+ const targetExists = await fileExists(join8(targetDir, file));
2913
+ const preserveExisting = Boolean(component.skipExisting) && targetExists;
2914
+ if (component.initOnly && !targetExists) {
2915
+ console.log(
2916
+ ` ! 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.`
2917
+ );
2918
+ continue;
2919
+ }
2399
2920
  if (preserveExisting) {
2400
2921
  console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
2401
2922
  } else if (options.dryRun) {
@@ -2405,10 +2926,12 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2405
2926
  await copyFile(srcPath, targetDir, file, { force: true });
2406
2927
  fileCount++;
2407
2928
  }
2408
- updatedFiles.push(file);
2929
+ if (!preserveExisting || installed.files.includes(file)) {
2930
+ updatedFiles.push(file);
2931
+ }
2409
2932
  }
2410
2933
  for (const dir of component.directories ?? []) {
2411
- const srcDirPath = join7(assetsDir, dir);
2934
+ const srcDirPath = join8(assetsDir, dir);
2412
2935
  if (options.dryRun) {
2413
2936
  console.log(` [dry-run] Would update directory: ${dir}/`);
2414
2937
  } else {
@@ -2436,7 +2959,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2436
2959
  }
2437
2960
  return;
2438
2961
  }
2439
- const retrofitPatch = await runManifestRetrofits(manifest);
2962
+ const interactive = Boolean(process.stdin.isTTY);
2963
+ const retrofitPatch = interactive ? await runManifestRetrofits(manifest) : {};
2964
+ if (!interactive) {
2965
+ const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
2966
+ if (pending.length > 0) {
2967
+ console.log(
2968
+ ` ! Skipped ${pending.length} manifest question${pending.length === 1 ? "" : "s"} (${pending.map((r) => r.field).join(", ")}) \u2014 no interactive terminal. Run \`spell update\` from a terminal to answer.`
2969
+ );
2970
+ }
2971
+ }
2440
2972
  const updated = {
2441
2973
  ...manifest,
2442
2974
  version: packageVersion,
@@ -2454,8 +2986,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2454
2986
  }
2455
2987
 
2456
2988
  // src/modules/version-check.ts
2457
- import { readFile as readFile4, writeFile as writeFile6, mkdir as mkdir4 } from "fs/promises";
2458
- import { dirname, join as join8 } from "path";
2989
+ import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
2990
+ import { dirname, join as join9 } from "path";
2459
2991
  import { homedir as homedir2 } from "os";
2460
2992
  import { createRequire } from "module";
2461
2993
  var require2 = createRequire(import.meta.url);
@@ -2469,11 +3001,11 @@ function getFeedUrl() {
2469
3001
  }
2470
3002
  return url;
2471
3003
  }
2472
- var DEFAULT_CACHE_PATH = join8(homedir2(), ".arcane", "version-cache.json");
3004
+ var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
2473
3005
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2474
3006
  async function readCache(cachePath) {
2475
3007
  try {
2476
- const content = await readFile4(cachePath, "utf-8");
3008
+ const content = await readFile5(cachePath, "utf-8");
2477
3009
  const parsed = JSON.parse(content);
2478
3010
  if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
2479
3011
  return null;
@@ -2484,9 +3016,9 @@ async function readCache(cachePath) {
2484
3016
  }
2485
3017
  }
2486
3018
  async function writeCache(cachePath, latest) {
2487
- await mkdir4(dirname(cachePath), { recursive: true });
3019
+ await mkdir5(dirname(cachePath), { recursive: true });
2488
3020
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
2489
- await writeFile6(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3021
+ await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
2490
3022
  }
2491
3023
  async function getLatestVersion(feedUrl) {
2492
3024
  const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
@@ -2599,7 +3131,7 @@ async function runStatus(targetDir, packageVersion) {
2599
3131
 
2600
3132
  // src/commands/uninstall.ts
2601
3133
  import { rm as rm2 } from "fs/promises";
2602
- import { join as join9 } from "path";
3134
+ import { join as join10 } from "path";
2603
3135
  import { confirm as confirm3 } from "@inquirer/prompts";
2604
3136
  async function runUninstall(options, targetDir) {
2605
3137
  let manifest;
@@ -2642,7 +3174,7 @@ async function runUninstall(options, targetDir) {
2642
3174
  let removed = 0;
2643
3175
  for (const component of manifest.components) {
2644
3176
  for (const file of component.files) {
2645
- const filePath = join9(targetDir, file);
3177
+ const filePath = join10(targetDir, file);
2646
3178
  try {
2647
3179
  await rm2(filePath, { force: true });
2648
3180
  removed++;
@@ -2650,16 +3182,16 @@ async function runUninstall(options, targetDir) {
2650
3182
  }
2651
3183
  }
2652
3184
  }
2653
- await rm2(join9(targetDir, ".arcane.json"), { force: true });
3185
+ await rm2(join10(targetDir, ".arcane.json"), { force: true });
2654
3186
  const agentDirs = [
2655
3187
  ".github/agents",
2656
3188
  ".arcane/agents",
2657
3189
  ".arcane/generated"
2658
3190
  ];
2659
3191
  for (const dir of agentDirs) {
2660
- await rm2(join9(targetDir, dir), { recursive: true, force: true });
3192
+ await rm2(join10(targetDir, dir), { recursive: true, force: true });
2661
3193
  }
2662
- await rm2(join9(targetDir, ".arcane/agents.yaml"), { force: true });
3194
+ await rm2(join10(targetDir, ".arcane/agents.yaml"), { force: true });
2663
3195
  const markerFiles = [
2664
3196
  "CLAUDE.md",
2665
3197
  "AGENTS.md",
@@ -2673,8 +3205,8 @@ async function runUninstall(options, targetDir) {
2673
3205
  }
2674
3206
 
2675
3207
  // src/commands/doctor.ts
2676
- import { access as access3, readFile as readFile5, mkdir as mkdir5, copyFile as fsCopyFile2 } from "fs/promises";
2677
- import { join as join10, dirname as dirname2 } from "path";
3208
+ import { access as access3, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3209
+ import { join as join11, dirname as dirname2 } from "path";
2678
3210
  import { execFile as execFile2 } from "child_process";
2679
3211
  import { promisify } from "util";
2680
3212
 
@@ -2762,8 +3294,8 @@ async function checkVSCodeExtension(id, label) {
2762
3294
  const name = `VS Code extension: ${label}`;
2763
3295
  const home = process.env["HOME"] ?? "";
2764
3296
  const extDirs = [
2765
- join10(home, ".vscode", "extensions"),
2766
- join10(home, ".vscode-insiders", "extensions")
3297
+ join11(home, ".vscode", "extensions"),
3298
+ join11(home, ".vscode-insiders", "extensions")
2767
3299
  ];
2768
3300
  for (const dir of extDirs) {
2769
3301
  try {
@@ -2797,13 +3329,13 @@ async function checkVSCodeExtension(id, label) {
2797
3329
  }
2798
3330
  async function checkArcaneManifest(targetDir) {
2799
3331
  const name = "Arcane manifest (.arcane.json)";
2800
- const manifestPath2 = join10(targetDir, ".arcane.json");
3332
+ const manifestPath2 = join11(targetDir, ".arcane.json");
2801
3333
  try {
2802
3334
  await access3(manifestPath2);
2803
3335
  } catch {
2804
- const selfHostedManifestPath = join10(targetDir, "src", "assets", ".arcane.json");
3336
+ const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
2805
3337
  try {
2806
- const raw = await readFile5(selfHostedManifestPath, "utf8");
3338
+ const raw = await readFile6(selfHostedManifestPath, "utf8");
2807
3339
  const manifest = JSON.parse(raw);
2808
3340
  if (manifest.selfHosted === true && manifest.tracking_mode === "internal") {
2809
3341
  return {
@@ -2822,7 +3354,7 @@ async function checkArcaneManifest(targetDir) {
2822
3354
  };
2823
3355
  }
2824
3356
  try {
2825
- const raw = await readFile5(manifestPath2, "utf8");
3357
+ const raw = await readFile6(manifestPath2, "utf8");
2826
3358
  const manifest = JSON.parse(raw);
2827
3359
  if (!manifest.version || !Array.isArray(manifest.components)) {
2828
3360
  return {
@@ -2893,7 +3425,7 @@ async function checkSessionContinuity(targetDir) {
2893
3425
  const results = [];
2894
3426
  for (const file of SESSION_CONTINUITY_FILES) {
2895
3427
  const name = `Session file: ${file}`;
2896
- const filePath = join10(targetDir, file);
3428
+ const filePath = join11(targetDir, file);
2897
3429
  try {
2898
3430
  await access3(filePath);
2899
3431
  results.push({ name, passed: true, message: "present" });
@@ -2911,18 +3443,69 @@ async function checkSessionContinuity(targetDir) {
2911
3443
  async function fixSessionContinuity(targetDir, assetsDir) {
2912
3444
  const created = [];
2913
3445
  for (const file of SESSION_CONTINUITY_FILES) {
2914
- const destPath = join10(targetDir, file);
3446
+ const destPath = join11(targetDir, file);
2915
3447
  try {
2916
3448
  await access3(destPath);
2917
3449
  } catch {
2918
- const srcPath = join10(assetsDir, file);
2919
- await mkdir5(dirname2(destPath), { recursive: true });
3450
+ const srcPath = join11(assetsDir, file);
3451
+ await mkdir6(dirname2(destPath), { recursive: true });
2920
3452
  await fsCopyFile2(srcPath, destPath);
2921
3453
  created.push(file);
2922
3454
  }
2923
3455
  }
2924
3456
  return created;
2925
3457
  }
3458
+ async function checkPushPolicy(targetDir) {
3459
+ const name = "Push policy (EF-09)";
3460
+ let manifest;
3461
+ try {
3462
+ manifest = await readManifest(targetDir);
3463
+ } catch {
3464
+ return { name, passed: true, message: "not initialized \u2014 nothing to check" };
3465
+ }
3466
+ const policy = manifest.push_policy ?? "open";
3467
+ if (policy === "open") {
3468
+ return { name, passed: true, message: "open \u2014 pushes allowed" };
3469
+ }
3470
+ if (policy === "guarded") {
3471
+ const remotes2 = await listRemotes(targetDir);
3472
+ const where = remotes2.length === 0 ? "none configured" : remotes2.join(", ");
3473
+ return {
3474
+ name,
3475
+ passed: false,
3476
+ blocking: false,
3477
+ message: `guarded \u2014 no technical control installed. Push targets: ${where}. Confirm those are the remotes you intend before pushing.`
3478
+ };
3479
+ }
3480
+ const hookActive = await isHookEnforced(targetDir);
3481
+ const remotes = await listRemotes(targetDir);
3482
+ const open = await undisabledRemotes(targetDir);
3483
+ if (hookActive && open.length === 0) {
3484
+ if (remotes.length === 0) {
3485
+ return {
3486
+ name,
3487
+ passed: false,
3488
+ blocking: false,
3489
+ message: "blocked \u2014 pre-push hook in place, but no remote is configured, so no push URL is disabled. If a remote is added later, run `spell doctor` again: a --no-verify push to it would succeed until the URL is disabled too."
3490
+ };
3491
+ }
3492
+ return {
3493
+ name,
3494
+ passed: true,
3495
+ message: `blocked \u2014 pre-push hook in place and push URL disabled for ${remotes.join(", ")}`
3496
+ };
3497
+ }
3498
+ const missing = [
3499
+ hookActive ? null : "pre-push hook (config or hook file absent)",
3500
+ open.length > 0 ? `push URL still live for: ${open.join(", ")}` : null
3501
+ ].filter(Boolean);
3502
+ return {
3503
+ name,
3504
+ passed: false,
3505
+ blocking: false,
3506
+ message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have.`
3507
+ };
3508
+ }
2926
3509
  async function runDoctor(targetDir, options = {}, assetsDir) {
2927
3510
  console.log("\nspell doctor \u2014 checking your Arcane environment\n");
2928
3511
  const results = await Promise.all([
@@ -2930,7 +3513,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
2930
3513
  checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
2931
3514
  checkArcaneManifest(targetDir),
2932
3515
  Promise.resolve(checkIncidentReleaseGate()),
2933
- checkPullRebase(targetDir)
3516
+ checkPullRebase(targetDir),
3517
+ checkPushPolicy(targetDir)
2934
3518
  ]);
2935
3519
  const sessionResults = await checkSessionContinuity(targetDir);
2936
3520
  results.push(...sessionResults);
@@ -2974,17 +3558,83 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
2974
3558
  }
2975
3559
  }
2976
3560
 
3561
+ // src/commands/unblock-push.ts
3562
+ import { basename, resolve } from "path";
3563
+ import { input as input4 } from "@inquirer/prompts";
3564
+ async function runUnblockPush(targetDir) {
3565
+ let manifest;
3566
+ try {
3567
+ manifest = await readManifest(targetDir);
3568
+ } catch (err) {
3569
+ if (err instanceof ManifestNotFoundError) {
3570
+ console.error('Not initialized. Run "spell init" first.');
3571
+ process.exit(1);
3572
+ return;
3573
+ }
3574
+ throw err;
3575
+ }
3576
+ const policy = manifest.push_policy ?? "open";
3577
+ if (policy === "open") {
3578
+ printInfo("This repository is not push-blocked \u2014 nothing to undo.");
3579
+ return;
3580
+ }
3581
+ if (!process.stdin.isTTY) {
3582
+ console.error(
3583
+ "spell unblock-push must be run from an interactive terminal.\nLifting a push block is a deliberate decision, so it cannot be scripted, piped, or run in CI."
3584
+ );
3585
+ process.exit(1);
3586
+ return;
3587
+ }
3588
+ const repoName = basename(resolve(targetDir));
3589
+ if (repoName === "") {
3590
+ console.error(
3591
+ "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
3592
+ );
3593
+ process.exit(1);
3594
+ return;
3595
+ }
3596
+ printWarning(`This repository's push_policy is "${policy}".`);
3597
+ printInfo(
3598
+ "Unblocking restores the ability to push this repository's full history to its remote. Deleting a file later does not undo a push: the content stays in history, in any clone already made, and in the remote's own backups."
3599
+ );
3600
+ console.log();
3601
+ const typed = await input4({
3602
+ message: `Type the repository name to confirm (${repoName}):`
3603
+ });
3604
+ if (typed.trim() !== repoName) {
3605
+ console.error(`
3606
+ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
3607
+ process.exit(1);
3608
+ return;
3609
+ }
3610
+ await removePrePushHook(targetDir);
3611
+ const restored = await restorePushUrls(targetDir);
3612
+ const updated = {
3613
+ ...manifest,
3614
+ push_policy: "open",
3615
+ push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
3616
+ };
3617
+ await writeManifest(targetDir, updated);
3618
+ printSuccess(`Push unblocked for "${repoName}".`);
3619
+ if (restored.length > 0) {
3620
+ printInfo(`Restored push URL for: ${restored.join(", ")}.`);
3621
+ }
3622
+ printInfo(
3623
+ "Recorded in .arcane.json as push_policy: open, with the time it was lifted. Commit that change so the repository's actual posture stays visible to everyone."
3624
+ );
3625
+ }
3626
+
2977
3627
  // src/index.ts
2978
3628
  import { realpathSync } from "fs";
2979
3629
  var require3 = createRequire2(import.meta.url);
2980
3630
  var pkg = require3("../package.json");
2981
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join11(dirname3(fileURLToPath(import.meta.url)), "assets");
3631
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname3(fileURLToPath(import.meta.url)), "assets");
2982
3632
  var program = new Command();
2983
3633
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
2984
3634
  await printWelcome(pkg.version, process.cwd());
2985
3635
  program.outputHelp();
2986
3636
  });
2987
- program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").action(
3637
+ 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(
2988
3638
  async (opts) => {
2989
3639
  await runInit(
2990
3640
  {
@@ -3023,6 +3673,9 @@ program.command("status").description("Show installed components and check for u
3023
3673
  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) => {
3024
3674
  await runUninstall({ yes: opts.yes, dryRun: opts.dryRun }, process.cwd());
3025
3675
  });
3676
+ program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
3677
+ await runUnblockPush(process.cwd());
3678
+ });
3026
3679
  program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
3027
3680
  await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
3028
3681
  });