arcane-cli 0.19.0 → 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
12
  import { select as select2, confirm, input as input2 } from "@inquirer/prompts";
13
- import { join as join4 } from "path";
13
+ import { join as join5 } from "path";
14
14
  import chalk2 from "chalk";
15
15
 
16
16
  // src/modules/copier.ts
@@ -99,6 +99,7 @@ var MANIFEST_FILE = ".arcane.json";
99
99
  var VALID_TRACKING_MODES = ["internal", "external"];
100
100
  var VALID_EXTERNAL_PROVIDERS = ["ado", "jira", "other"];
101
101
  var VALID_CONTENT_SENSITIVITY = ["standard", "sensitive"];
102
+ var VALID_PUSH_POLICIES = ["open", "guarded", "blocked"];
102
103
  function isValidSubjectRoot(value) {
103
104
  const invalid = value.trim() === "" || path2.isAbsolute(value) || // Windows drive-relative ("C:docs") and UNC-ish values also escape.
104
105
  /^[a-zA-Z]:/.test(value) || value.startsWith("\\\\") || value.startsWith("\\") || value.split(/[\\/]/).includes("..") || // A stored subject_root is interpolated into agent prompts downstream, so
@@ -128,6 +129,9 @@ function validateTrackingFields(manifest, filePath) {
128
129
  manifest.content_sensitivity
129
130
  );
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
+ }
131
135
  if (manifest.subject_root !== void 0 && manifest.subject_root !== null) {
132
136
  if (typeof manifest.subject_root !== "string") {
133
137
  throw new ManifestInvalidFieldError(filePath, "subject_root", manifest.subject_root);
@@ -668,7 +672,7 @@ var WORDMARK = [
668
672
  var INDENT = " ";
669
673
  var LOCKUP_WIDTH = INDENT.length + Math.max(...WORDMARK.map((l) => l.length));
670
674
  function sleep(ms) {
671
- return new Promise((resolve) => setTimeout(resolve, ms));
675
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
672
676
  }
673
677
  function isAnimatable() {
674
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;
@@ -1790,6 +1794,10 @@ async function runAgentsList(targetDir) {
1790
1794
  console.log();
1791
1795
  }
1792
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
+
1793
1801
  // src/modules/git.ts
1794
1802
  import { execFile } from "child_process";
1795
1803
  var GitTimeoutError = class extends Error {
@@ -1885,7 +1893,7 @@ function buildGitEnv() {
1885
1893
  function runGit(cwd, args, options = {}) {
1886
1894
  const commandClass = options.commandClass ?? classifyGitCommand(args);
1887
1895
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
1888
- return new Promise((resolve, reject) => {
1896
+ return new Promise((resolve2, reject) => {
1889
1897
  const child = execFile(
1890
1898
  "git",
1891
1899
  args,
@@ -1919,7 +1927,7 @@ function runGit(cwd, args, options = {}) {
1919
1927
  reject(error);
1920
1928
  return;
1921
1929
  }
1922
- resolve({ stdout, stderr });
1930
+ resolve2({ stdout, stderr });
1923
1931
  }
1924
1932
  );
1925
1933
  child.stdin?.end();
@@ -2008,6 +2016,146 @@ async function ensureLocalPullRebase(cwd) {
2008
2016
  }
2009
2017
  }
2010
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
+
2011
2159
  // src/commands/init.ts
2012
2160
  var VALID_PROFILES = ["full", "lite", "governance-only", "methodology", "docs"];
2013
2161
  var PROFILE_ICONS = {
@@ -2170,7 +2318,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2170
2318
  for (const component of components) {
2171
2319
  const installedFiles = [];
2172
2320
  for (const file of component.files) {
2173
- const srcPath = join4(assetsDir, component.sourceOverrides?.[file] ?? file);
2321
+ const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
2174
2322
  if (options.dryRun) {
2175
2323
  printDryRun(`Would copy: ${file}`);
2176
2324
  } else {
@@ -2187,7 +2335,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2187
2335
  fileCount++;
2188
2336
  }
2189
2337
  for (const dir of component.directories ?? []) {
2190
- const srcDirPath = join4(assetsDir, dir);
2338
+ const srcDirPath = join5(assetsDir, dir);
2191
2339
  if (options.dryRun) {
2192
2340
  printDryRun(`Would copy directory: ${dir}/`);
2193
2341
  } else {
@@ -2212,7 +2360,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2212
2360
  if (options.dryRun) {
2213
2361
  if (hasAgents) {
2214
2362
  const agentFiles = await import("fs").then(
2215
- (fsModule) => fsModule.promises.readdir(join4(assetsDir, "agents"))
2363
+ (fsModule) => fsModule.promises.readdir(join5(assetsDir, "agents"))
2216
2364
  );
2217
2365
  const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
2218
2366
  printDryRun("Would run agent setup (interactive):");
@@ -2302,6 +2450,22 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2302
2450
  default: "standard"
2303
2451
  });
2304
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
+ }
2305
2469
  const manifest = {
2306
2470
  version: packageVersion,
2307
2471
  profile,
@@ -2310,10 +2474,44 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2310
2474
  ...role ? { role } : {},
2311
2475
  ...tracking_mode ? { tracking_mode, external_provider } : {},
2312
2476
  ...subject_root !== void 0 ? { subject_root } : {},
2313
- ...content_sensitivity ? { content_sensitivity } : {}
2477
+ ...content_sensitivity ? { content_sensitivity } : {},
2478
+ ...push_policy ? { push_policy } : {}
2314
2479
  };
2315
2480
  await writeManifest(targetDir, manifest);
2316
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
+ }
2317
2515
  let agentsConfigured = false;
2318
2516
  if (hasAgents && !options.profile) {
2319
2517
  console.log();
@@ -2359,7 +2557,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2359
2557
  }
2360
2558
 
2361
2559
  // src/commands/add.ts
2362
- import { join as join5 } from "path";
2560
+ import { join as join6 } from "path";
2363
2561
  async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2364
2562
  let manifest;
2365
2563
  try {
@@ -2394,7 +2592,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2394
2592
  let fileCount = 0;
2395
2593
  const installedFiles = [];
2396
2594
  for (const file of component.files) {
2397
- const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
2595
+ const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
2398
2596
  if (options.dryRun) {
2399
2597
  console.log(` [dry-run] Would copy: ${file}`);
2400
2598
  } else {
@@ -2404,7 +2602,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2404
2602
  fileCount++;
2405
2603
  }
2406
2604
  for (const dir of component.directories ?? []) {
2407
- const srcDirPath = join5(assetsDir, dir);
2605
+ const srcDirPath = join6(assetsDir, dir);
2408
2606
  if (options.dryRun) {
2409
2607
  console.log(` [dry-run] Would copy directory: ${dir}/`);
2410
2608
  } else {
@@ -2434,12 +2632,12 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2434
2632
  }
2435
2633
 
2436
2634
  // src/commands/update.ts
2437
- import { join as join7 } from "path";
2635
+ import { join as join8 } from "path";
2438
2636
 
2439
2637
  // src/modules/hub.ts
2440
2638
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
2441
- import { readdir as readdir3, writeFile as writeFile5 } from "fs/promises";
2442
- import { join as join6 } from "path";
2639
+ import { readdir as readdir3, writeFile as writeFile6 } from "fs/promises";
2640
+ import { join as join7 } from "path";
2443
2641
  var MANIFEST_RETROFITS = [
2444
2642
  {
2445
2643
  field: "role",
@@ -2539,6 +2737,27 @@ var MANIFEST_RETROFITS = [
2539
2737
  }
2540
2738
  return { subject_root: null };
2541
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
+ }
2542
2761
  }
2543
2762
  ];
2544
2763
  async function runManifestRetrofits(manifest) {
@@ -2556,9 +2775,9 @@ async function runManifestRetrofits(manifest) {
2556
2775
  return patch;
2557
2776
  }
2558
2777
  async function offerRegistryScaffold(targetDir, businessRoot) {
2559
- const registryPath = join6(targetDir, businessRoot, "registry.json");
2778
+ const registryPath = join7(targetDir, businessRoot, "registry.json");
2560
2779
  if (await fileExists(registryPath)) return;
2561
- const businessRootPath = join6(targetDir, businessRoot);
2780
+ const businessRootPath = join7(targetDir, businessRoot);
2562
2781
  let ventureDirs;
2563
2782
  try {
2564
2783
  const entries = await readdir3(businessRootPath, { withFileTypes: true });
@@ -2596,7 +2815,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
2596
2815
  updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
2597
2816
  ventures
2598
2817
  };
2599
- await writeFile5(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
2818
+ await writeFile6(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
2600
2819
  console.log(
2601
2820
  ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
2602
2821
  );
@@ -2689,8 +2908,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2689
2908
  }
2690
2909
  const updatedFiles = [];
2691
2910
  for (const file of component.files) {
2692
- const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
2693
- const targetExists = await fileExists(join7(targetDir, file));
2911
+ const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
2912
+ const targetExists = await fileExists(join8(targetDir, file));
2694
2913
  const preserveExisting = Boolean(component.skipExisting) && targetExists;
2695
2914
  if (component.initOnly && !targetExists) {
2696
2915
  console.log(
@@ -2712,7 +2931,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2712
2931
  }
2713
2932
  }
2714
2933
  for (const dir of component.directories ?? []) {
2715
- const srcDirPath = join7(assetsDir, dir);
2934
+ const srcDirPath = join8(assetsDir, dir);
2716
2935
  if (options.dryRun) {
2717
2936
  console.log(` [dry-run] Would update directory: ${dir}/`);
2718
2937
  } else {
@@ -2767,8 +2986,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2767
2986
  }
2768
2987
 
2769
2988
  // src/modules/version-check.ts
2770
- import { readFile as readFile4, writeFile as writeFile6, mkdir as mkdir4 } from "fs/promises";
2771
- 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";
2772
2991
  import { homedir as homedir2 } from "os";
2773
2992
  import { createRequire } from "module";
2774
2993
  var require2 = createRequire(import.meta.url);
@@ -2782,11 +3001,11 @@ function getFeedUrl() {
2782
3001
  }
2783
3002
  return url;
2784
3003
  }
2785
- var DEFAULT_CACHE_PATH = join8(homedir2(), ".arcane", "version-cache.json");
3004
+ var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
2786
3005
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2787
3006
  async function readCache(cachePath) {
2788
3007
  try {
2789
- const content = await readFile4(cachePath, "utf-8");
3008
+ const content = await readFile5(cachePath, "utf-8");
2790
3009
  const parsed = JSON.parse(content);
2791
3010
  if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
2792
3011
  return null;
@@ -2797,9 +3016,9 @@ async function readCache(cachePath) {
2797
3016
  }
2798
3017
  }
2799
3018
  async function writeCache(cachePath, latest) {
2800
- await mkdir4(dirname(cachePath), { recursive: true });
3019
+ await mkdir5(dirname(cachePath), { recursive: true });
2801
3020
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
2802
- await writeFile6(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3021
+ await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
2803
3022
  }
2804
3023
  async function getLatestVersion(feedUrl) {
2805
3024
  const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
@@ -2912,7 +3131,7 @@ async function runStatus(targetDir, packageVersion) {
2912
3131
 
2913
3132
  // src/commands/uninstall.ts
2914
3133
  import { rm as rm2 } from "fs/promises";
2915
- import { join as join9 } from "path";
3134
+ import { join as join10 } from "path";
2916
3135
  import { confirm as confirm3 } from "@inquirer/prompts";
2917
3136
  async function runUninstall(options, targetDir) {
2918
3137
  let manifest;
@@ -2955,7 +3174,7 @@ async function runUninstall(options, targetDir) {
2955
3174
  let removed = 0;
2956
3175
  for (const component of manifest.components) {
2957
3176
  for (const file of component.files) {
2958
- const filePath = join9(targetDir, file);
3177
+ const filePath = join10(targetDir, file);
2959
3178
  try {
2960
3179
  await rm2(filePath, { force: true });
2961
3180
  removed++;
@@ -2963,16 +3182,16 @@ async function runUninstall(options, targetDir) {
2963
3182
  }
2964
3183
  }
2965
3184
  }
2966
- await rm2(join9(targetDir, ".arcane.json"), { force: true });
3185
+ await rm2(join10(targetDir, ".arcane.json"), { force: true });
2967
3186
  const agentDirs = [
2968
3187
  ".github/agents",
2969
3188
  ".arcane/agents",
2970
3189
  ".arcane/generated"
2971
3190
  ];
2972
3191
  for (const dir of agentDirs) {
2973
- await rm2(join9(targetDir, dir), { recursive: true, force: true });
3192
+ await rm2(join10(targetDir, dir), { recursive: true, force: true });
2974
3193
  }
2975
- await rm2(join9(targetDir, ".arcane/agents.yaml"), { force: true });
3194
+ await rm2(join10(targetDir, ".arcane/agents.yaml"), { force: true });
2976
3195
  const markerFiles = [
2977
3196
  "CLAUDE.md",
2978
3197
  "AGENTS.md",
@@ -2986,8 +3205,8 @@ async function runUninstall(options, targetDir) {
2986
3205
  }
2987
3206
 
2988
3207
  // src/commands/doctor.ts
2989
- import { access as access3, readFile as readFile5, mkdir as mkdir5, copyFile as fsCopyFile2 } from "fs/promises";
2990
- 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";
2991
3210
  import { execFile as execFile2 } from "child_process";
2992
3211
  import { promisify } from "util";
2993
3212
 
@@ -3075,8 +3294,8 @@ async function checkVSCodeExtension(id, label) {
3075
3294
  const name = `VS Code extension: ${label}`;
3076
3295
  const home = process.env["HOME"] ?? "";
3077
3296
  const extDirs = [
3078
- join10(home, ".vscode", "extensions"),
3079
- join10(home, ".vscode-insiders", "extensions")
3297
+ join11(home, ".vscode", "extensions"),
3298
+ join11(home, ".vscode-insiders", "extensions")
3080
3299
  ];
3081
3300
  for (const dir of extDirs) {
3082
3301
  try {
@@ -3110,13 +3329,13 @@ async function checkVSCodeExtension(id, label) {
3110
3329
  }
3111
3330
  async function checkArcaneManifest(targetDir) {
3112
3331
  const name = "Arcane manifest (.arcane.json)";
3113
- const manifestPath2 = join10(targetDir, ".arcane.json");
3332
+ const manifestPath2 = join11(targetDir, ".arcane.json");
3114
3333
  try {
3115
3334
  await access3(manifestPath2);
3116
3335
  } catch {
3117
- const selfHostedManifestPath = join10(targetDir, "src", "assets", ".arcane.json");
3336
+ const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
3118
3337
  try {
3119
- const raw = await readFile5(selfHostedManifestPath, "utf8");
3338
+ const raw = await readFile6(selfHostedManifestPath, "utf8");
3120
3339
  const manifest = JSON.parse(raw);
3121
3340
  if (manifest.selfHosted === true && manifest.tracking_mode === "internal") {
3122
3341
  return {
@@ -3135,7 +3354,7 @@ async function checkArcaneManifest(targetDir) {
3135
3354
  };
3136
3355
  }
3137
3356
  try {
3138
- const raw = await readFile5(manifestPath2, "utf8");
3357
+ const raw = await readFile6(manifestPath2, "utf8");
3139
3358
  const manifest = JSON.parse(raw);
3140
3359
  if (!manifest.version || !Array.isArray(manifest.components)) {
3141
3360
  return {
@@ -3206,7 +3425,7 @@ async function checkSessionContinuity(targetDir) {
3206
3425
  const results = [];
3207
3426
  for (const file of SESSION_CONTINUITY_FILES) {
3208
3427
  const name = `Session file: ${file}`;
3209
- const filePath = join10(targetDir, file);
3428
+ const filePath = join11(targetDir, file);
3210
3429
  try {
3211
3430
  await access3(filePath);
3212
3431
  results.push({ name, passed: true, message: "present" });
@@ -3224,18 +3443,69 @@ async function checkSessionContinuity(targetDir) {
3224
3443
  async function fixSessionContinuity(targetDir, assetsDir) {
3225
3444
  const created = [];
3226
3445
  for (const file of SESSION_CONTINUITY_FILES) {
3227
- const destPath = join10(targetDir, file);
3446
+ const destPath = join11(targetDir, file);
3228
3447
  try {
3229
3448
  await access3(destPath);
3230
3449
  } catch {
3231
- const srcPath = join10(assetsDir, file);
3232
- await mkdir5(dirname2(destPath), { recursive: true });
3450
+ const srcPath = join11(assetsDir, file);
3451
+ await mkdir6(dirname2(destPath), { recursive: true });
3233
3452
  await fsCopyFile2(srcPath, destPath);
3234
3453
  created.push(file);
3235
3454
  }
3236
3455
  }
3237
3456
  return created;
3238
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
+ }
3239
3509
  async function runDoctor(targetDir, options = {}, assetsDir) {
3240
3510
  console.log("\nspell doctor \u2014 checking your Arcane environment\n");
3241
3511
  const results = await Promise.all([
@@ -3243,7 +3513,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
3243
3513
  checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
3244
3514
  checkArcaneManifest(targetDir),
3245
3515
  Promise.resolve(checkIncidentReleaseGate()),
3246
- checkPullRebase(targetDir)
3516
+ checkPullRebase(targetDir),
3517
+ checkPushPolicy(targetDir)
3247
3518
  ]);
3248
3519
  const sessionResults = await checkSessionContinuity(targetDir);
3249
3520
  results.push(...sessionResults);
@@ -3287,11 +3558,77 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
3287
3558
  }
3288
3559
  }
3289
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
+
3290
3627
  // src/index.ts
3291
3628
  import { realpathSync } from "fs";
3292
3629
  var require3 = createRequire2(import.meta.url);
3293
3630
  var pkg = require3("../package.json");
3294
- 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");
3295
3632
  var program = new Command();
3296
3633
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
3297
3634
  await printWelcome(pkg.version, process.cwd());
@@ -3336,6 +3673,9 @@ program.command("status").description("Show installed components and check for u
3336
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) => {
3337
3674
  await runUninstall({ yes: opts.yes, dryRun: opts.dryRun }, process.cwd());
3338
3675
  });
3676
+ program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
3677
+ await runUnblockPush(process.cwd());
3678
+ });
3339
3679
  program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
3340
3680
  await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
3341
3681
  });