arcane-cli 0.19.0 → 0.20.1

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((resolve3) => setTimeout(resolve3, 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,11 @@ 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, access as access3, rm as rm2 } from "fs/promises";
1799
+ import { constants } from "fs";
1800
+ import { join as join4, resolve } from "path";
1801
+
1793
1802
  // src/modules/git.ts
1794
1803
  import { execFile } from "child_process";
1795
1804
  var GitTimeoutError = class extends Error {
@@ -1885,7 +1894,7 @@ function buildGitEnv() {
1885
1894
  function runGit(cwd, args, options = {}) {
1886
1895
  const commandClass = options.commandClass ?? classifyGitCommand(args);
1887
1896
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
1888
- return new Promise((resolve, reject) => {
1897
+ return new Promise((resolve3, reject) => {
1889
1898
  const child = execFile(
1890
1899
  "git",
1891
1900
  args,
@@ -1919,7 +1928,7 @@ function runGit(cwd, args, options = {}) {
1919
1928
  reject(error);
1920
1929
  return;
1921
1930
  }
1922
- resolve({ stdout, stderr });
1931
+ resolve3({ stdout, stderr });
1923
1932
  }
1924
1933
  );
1925
1934
  child.stdin?.end();
@@ -2008,6 +2017,317 @@ async function ensureLocalPullRebase(cwd) {
2008
2017
  }
2009
2018
  }
2010
2019
 
2020
+ // src/modules/push-safety.ts
2021
+ var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
2022
+ var NO_PUSH_URL_MARKER = (remote) => `remote.${remote}.arcaneHadNoPushUrl`;
2023
+ var ARCANE_HOOKS_DIR = ".arcane/hooks";
2024
+ async function readHooksPath(cwd) {
2025
+ let raw;
2026
+ try {
2027
+ const { stdout } = await runGit(cwd, ["config", "--show-scope", "--get", "core.hooksPath"]);
2028
+ raw = stdout.trim();
2029
+ } catch (error) {
2030
+ if (error.code !== 1) {
2031
+ return { value: "", scope: "unknown", unreadable: true };
2032
+ }
2033
+ return void 0;
2034
+ }
2035
+ if (raw === "") return void 0;
2036
+ const separator = raw.indexOf(" ");
2037
+ if (separator === -1) return { value: raw, scope: "unknown" };
2038
+ const scope = raw.slice(0, separator).trim();
2039
+ const value = raw.slice(separator + 1).trim();
2040
+ if (value === "") return void 0;
2041
+ const known = ["local", "worktree", "global", "system", "command"];
2042
+ const matched = known.find((candidate) => candidate === scope);
2043
+ return { value, scope: matched ?? "unknown" };
2044
+ }
2045
+ function isArcaneHooksPath(cwd, value) {
2046
+ const windows = process.platform === "win32";
2047
+ const normalize = (p) => {
2048
+ const separated = windows ? p.replace(/\\/g, "/") : p;
2049
+ const absolute = resolve(cwd, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2050
+ return windows ? absolute.toLowerCase() : absolute;
2051
+ };
2052
+ return normalize(value) === normalize(ARCANE_HOOKS_DIR);
2053
+ }
2054
+ var HOOK_BODY = `#!/bin/sh
2055
+ # Installed by Arcane because this repository's .arcane.json sets
2056
+ # push_policy: "blocked".
2057
+ #
2058
+ # This is deliberately not tamper-proof. It guards against an accidental push
2059
+ # -- the wrong remote, muscle memory, an unsupervised agent -- not against a
2060
+ # determined operator, who can always bypass it.
2061
+ #
2062
+ # To push legitimately, undo the policy first:
2063
+ #
2064
+ # spell unblock-push
2065
+ #
2066
+ # That command is interactive-only and asks you to confirm the repository by
2067
+ # name. Do not work around this hook by other means: the manifest would then
2068
+ # claim a protection this repository no longer has.
2069
+ echo "arcane: push blocked (push_policy: blocked). Run 'spell unblock-push' to undo." >&2
2070
+ exit 1
2071
+ `;
2072
+ function describeConfigScope(scope) {
2073
+ switch (scope) {
2074
+ case "local":
2075
+ return "set for this repository";
2076
+ case "worktree":
2077
+ return "set for this worktree";
2078
+ case "global":
2079
+ return "set globally, for your user";
2080
+ case "system":
2081
+ return "set system-wide";
2082
+ case "command":
2083
+ return "set on the command line";
2084
+ default:
2085
+ return "scope unknown";
2086
+ }
2087
+ }
2088
+ function hookFilePath(cwd) {
2089
+ return join4(cwd, ARCANE_HOOKS_DIR, "pre-push");
2090
+ }
2091
+ async function installPrePushHook(cwd) {
2092
+ const existing = await readHooksPath(cwd);
2093
+ if (existing?.unreadable === true) return { status: "refused-unreadable-config" };
2094
+ if (existing !== void 0 && !isArcaneHooksPath(cwd, existing.value)) {
2095
+ return {
2096
+ status: "refused-foreign-hooks-path",
2097
+ existing: existing.value,
2098
+ scope: existing.scope
2099
+ };
2100
+ }
2101
+ const hookPath = hookFilePath(cwd);
2102
+ if (existing !== void 0 && await hookBodyMatches(cwd)) return { status: "already-ours" };
2103
+ await mkdir4(join4(cwd, ARCANE_HOOKS_DIR), { recursive: true });
2104
+ await writeFile5(hookPath, HOOK_BODY, "utf-8");
2105
+ await chmod(hookPath, 493);
2106
+ await runGit(cwd, ["config", "--local", "core.hooksPath", ARCANE_HOOKS_DIR]);
2107
+ return { status: "installed", path: hookPath };
2108
+ }
2109
+ async function isHookEnforced(cwd) {
2110
+ const hooksPath = await readHooksPath(cwd);
2111
+ if (hooksPath === void 0 || !isArcaneHooksPath(cwd, hooksPath.value)) return false;
2112
+ return hookBodyMatches(cwd);
2113
+ }
2114
+ async function hookBodyMatches(cwd) {
2115
+ const hookPath = hookFilePath(cwd);
2116
+ if (!await fileExists(hookPath)) return false;
2117
+ try {
2118
+ if (await readFile4(hookPath, "utf-8") !== HOOK_BODY) return false;
2119
+ } catch {
2120
+ return false;
2121
+ }
2122
+ if (process.platform === "win32") return true;
2123
+ try {
2124
+ await access3(hookPath, constants.X_OK);
2125
+ return true;
2126
+ } catch {
2127
+ return false;
2128
+ }
2129
+ }
2130
+ async function removePrePushHook(cwd) {
2131
+ const existing = await readHooksPath(cwd);
2132
+ if (existing === void 0 || existing.unreadable === true) return;
2133
+ if (!isArcaneHooksPath(cwd, existing.value)) return;
2134
+ if (existing.scope !== "local" && existing.scope !== "worktree") return;
2135
+ const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2136
+ try {
2137
+ await runGit(cwd, ["config", scopeFlag, "--unset", "core.hooksPath"]);
2138
+ } catch {
2139
+ }
2140
+ try {
2141
+ await rm2(hookFilePath(cwd), { force: true });
2142
+ } catch {
2143
+ }
2144
+ }
2145
+ async function listRemotes(cwd) {
2146
+ try {
2147
+ const { stdout } = await runGit(cwd, ["remote"]);
2148
+ return stdout.split("\n").map((r) => r.trim()).filter(Boolean);
2149
+ } catch {
2150
+ return [];
2151
+ }
2152
+ }
2153
+ async function pushTargets(cwd) {
2154
+ const targets = [];
2155
+ for (const remote of await listRemotes(cwd)) {
2156
+ try {
2157
+ const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", "--all", remote]);
2158
+ targets.push({
2159
+ remote,
2160
+ urls: stdout.split("\n").map((line) => line.trim()).filter(Boolean)
2161
+ });
2162
+ } catch {
2163
+ targets.push({ remote, urls: [] });
2164
+ }
2165
+ }
2166
+ return targets;
2167
+ }
2168
+ function originalPushUrlKey(remote) {
2169
+ return `remote.${remote}.arcaneOriginalPushUrl`;
2170
+ }
2171
+ async function readLocalAll(cwd, key) {
2172
+ try {
2173
+ const { stdout } = await runGit(cwd, ["config", "--local", "--get-all", key]);
2174
+ return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
2175
+ } catch {
2176
+ return [];
2177
+ }
2178
+ }
2179
+ async function readScopedAll(cwd, key) {
2180
+ let stdout;
2181
+ try {
2182
+ ({ stdout } = await runGit(cwd, ["config", "--show-scope", "--get-all", key]));
2183
+ } catch {
2184
+ return [];
2185
+ }
2186
+ return stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean).map((line) => {
2187
+ const separator = line.indexOf(" ");
2188
+ if (separator === -1) return { scope: "unknown", value: line.trim() };
2189
+ return { scope: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
2190
+ }).filter((entry) => entry.value !== "");
2191
+ }
2192
+ async function effectivePushUrls(cwd, remote) {
2193
+ const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
2194
+ return scoped.map((entry) => entry.value);
2195
+ }
2196
+ async function disablePushUrls(cwd) {
2197
+ const results = [];
2198
+ for (const remote of await listRemotes(cwd)) {
2199
+ try {
2200
+ results.push(await disableOneRemote(cwd, remote));
2201
+ } catch (error) {
2202
+ results.push({ remote, status: "failed", reason: describeError(error) });
2203
+ }
2204
+ }
2205
+ return results;
2206
+ }
2207
+ async function disableOneRemote(cwd, remote) {
2208
+ const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
2209
+ const foreign = scoped.filter((entry) => entry.scope !== "local" && entry.value !== DISABLED_PUSH_URL);
2210
+ if (foreign.length > 0) {
2211
+ const where = [...new Set(foreign.map((entry) => entry.scope))].join(", ");
2212
+ return {
2213
+ remote,
2214
+ status: "failed",
2215
+ reason: `a push URL for this remote is configured at ${where} scope, which this repository cannot override \u2014 git would still deliver the push there. Remove it with \`git config --${where.split(", ")[0]} --unset-all remote.${remote}.pushurl\` and re-apply.`
2216
+ };
2217
+ }
2218
+ const currentPushUrls = scoped.map((entry) => entry.value);
2219
+ if (currentPushUrls.length === 1 && currentPushUrls[0] === DISABLED_PUSH_URL) {
2220
+ return { remote, status: "already-disabled" };
2221
+ }
2222
+ const key = originalPushUrlKey(remote);
2223
+ if ((await readLocalAll(cwd, key)).length === 0 && !await hadNoPushUrl(cwd, remote)) {
2224
+ if (currentPushUrls.length > 0) {
2225
+ await runGit(cwd, ["config", "--local", "--replace-all", key, currentPushUrls[0]]);
2226
+ for (const extra of currentPushUrls.slice(1)) {
2227
+ await runGit(cwd, ["config", "--local", "--add", key, extra]);
2228
+ }
2229
+ } else {
2230
+ await runGit(cwd, ["config", "--local", NO_PUSH_URL_MARKER(remote), "true"]);
2231
+ }
2232
+ }
2233
+ await runGit(cwd, [
2234
+ "config",
2235
+ "--local",
2236
+ "--replace-all",
2237
+ `remote.${remote}.pushurl`,
2238
+ DISABLED_PUSH_URL
2239
+ ]);
2240
+ return { remote, status: "disabled" };
2241
+ }
2242
+ async function undisabledRemotes(cwd) {
2243
+ const open = [];
2244
+ for (const remote of await listRemotes(cwd)) {
2245
+ const urls = await effectivePushUrls(cwd, remote);
2246
+ if (urls.length === 0 || urls.some((url) => url !== DISABLED_PUSH_URL)) open.push(remote);
2247
+ }
2248
+ return open;
2249
+ }
2250
+ async function blockedRemotes(cwd) {
2251
+ const blocked = [];
2252
+ for (const remote of await listRemotes(cwd)) {
2253
+ const urls = await effectivePushUrls(cwd, remote);
2254
+ if (urls.some((url) => url === DISABLED_PUSH_URL)) blocked.push(remote);
2255
+ }
2256
+ return blocked;
2257
+ }
2258
+ async function restorePushUrls(cwd) {
2259
+ const results = [];
2260
+ for (const remote of await listRemotes(cwd)) {
2261
+ try {
2262
+ results.push(await restoreOneRemote(cwd, remote));
2263
+ } catch (error) {
2264
+ results.push({ remote, status: "failed", reason: describeError(error) });
2265
+ }
2266
+ }
2267
+ return results;
2268
+ }
2269
+ async function restoreOneRemote(cwd, remote) {
2270
+ const key = originalPushUrlKey(remote);
2271
+ let recorded = await readLocalAll(cwd, key);
2272
+ let hadNone = await hadNoPushUrl(cwd, remote);
2273
+ if (recorded.length === 0 && !hadNone) {
2274
+ const legacy = await readLegacyRecord(cwd, remote);
2275
+ if (legacy.status !== "usable") return { remote, status: legacy.status, reason: legacy.reason };
2276
+ recorded = legacy.values;
2277
+ hadNone = false;
2278
+ }
2279
+ if (hadNone) {
2280
+ await runGit(cwd, ["config", "--local", "--unset-all", `remote.${remote}.pushurl`]);
2281
+ } else {
2282
+ await runGit(cwd, [
2283
+ "config",
2284
+ "--local",
2285
+ "--replace-all",
2286
+ `remote.${remote}.pushurl`,
2287
+ recorded[0]
2288
+ ]);
2289
+ for (const extra of recorded.slice(1)) {
2290
+ await runGit(cwd, ["config", "--local", "--add", `remote.${remote}.pushurl`, extra]);
2291
+ }
2292
+ }
2293
+ for (const staleKey of [key, NO_PUSH_URL_MARKER(remote), `arcane.originalPushUrl.${remote}`]) {
2294
+ try {
2295
+ await runGit(cwd, ["config", "--local", "--unset-all", staleKey]);
2296
+ } catch {
2297
+ }
2298
+ }
2299
+ return { remote, status: "restored" };
2300
+ }
2301
+ async function hadNoPushUrl(cwd, remote) {
2302
+ return (await readLocalAll(cwd, NO_PUSH_URL_MARKER(remote)))[0] === "true";
2303
+ }
2304
+ async function readLegacyRecord(cwd, remote) {
2305
+ const values = await readLocalAll(cwd, `arcane.originalPushUrl.${remote}`);
2306
+ if (values.length === 0) return { status: "nothing-recorded" };
2307
+ const current = await effectivePushUrls(cwd, remote);
2308
+ if (!current.includes(DISABLED_PUSH_URL)) {
2309
+ return {
2310
+ status: "failed",
2311
+ reason: `found a push URL recorded by 0.20.0 ("${values.join(", ")}") but this remote is not currently blocked by Arcane, so the record may belong to a different remote of the same name. Not applying it \u2014 set the URL yourself if it is correct.`
2312
+ };
2313
+ }
2314
+ const names = await listRemotes(cwd);
2315
+ const collides = names.some(
2316
+ (other) => other !== remote && other.toLowerCase() === remote.toLowerCase()
2317
+ );
2318
+ if (collides) {
2319
+ return {
2320
+ status: "failed",
2321
+ reason: `this repository has remotes whose names differ only by case, and 0.20.0 stored their original push URLs under one shared key, so the recorded value ("${values.join(", ")}") cannot be attributed to a specific remote. Not applying it \u2014 set the URLs yourself.`
2322
+ };
2323
+ }
2324
+ return { status: "usable", values };
2325
+ }
2326
+ function describeError(error) {
2327
+ const message = error instanceof Error ? error.message : String(error);
2328
+ return message.split("\n")[0].trim();
2329
+ }
2330
+
2011
2331
  // src/commands/init.ts
2012
2332
  var VALID_PROFILES = ["full", "lite", "governance-only", "methodology", "docs"];
2013
2333
  var PROFILE_ICONS = {
@@ -2170,7 +2490,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2170
2490
  for (const component of components) {
2171
2491
  const installedFiles = [];
2172
2492
  for (const file of component.files) {
2173
- const srcPath = join4(assetsDir, component.sourceOverrides?.[file] ?? file);
2493
+ const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
2174
2494
  if (options.dryRun) {
2175
2495
  printDryRun(`Would copy: ${file}`);
2176
2496
  } else {
@@ -2187,7 +2507,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2187
2507
  fileCount++;
2188
2508
  }
2189
2509
  for (const dir of component.directories ?? []) {
2190
- const srcDirPath = join4(assetsDir, dir);
2510
+ const srcDirPath = join5(assetsDir, dir);
2191
2511
  if (options.dryRun) {
2192
2512
  printDryRun(`Would copy directory: ${dir}/`);
2193
2513
  } else {
@@ -2212,7 +2532,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2212
2532
  if (options.dryRun) {
2213
2533
  if (hasAgents) {
2214
2534
  const agentFiles = await import("fs").then(
2215
- (fsModule) => fsModule.promises.readdir(join4(assetsDir, "agents"))
2535
+ (fsModule) => fsModule.promises.readdir(join5(assetsDir, "agents"))
2216
2536
  );
2217
2537
  const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
2218
2538
  printDryRun("Would run agent setup (interactive):");
@@ -2302,6 +2622,22 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2302
2622
  default: "standard"
2303
2623
  });
2304
2624
  }
2625
+ let push_policy;
2626
+ if (!options.profile) {
2627
+ console.log();
2628
+ push_policy = await select2({
2629
+ message: "Should this repository be allowed to push to a remote?",
2630
+ choices: [
2631
+ { value: "open", name: "Yes \u2014 normal repository" },
2632
+ { value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
2633
+ {
2634
+ value: "blocked",
2635
+ name: "No \u2014 block pushes (a pre-push hook plus a disabled push URL)"
2636
+ }
2637
+ ],
2638
+ default: "open"
2639
+ });
2640
+ }
2305
2641
  const manifest = {
2306
2642
  version: packageVersion,
2307
2643
  profile,
@@ -2310,10 +2646,55 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2310
2646
  ...role ? { role } : {},
2311
2647
  ...tracking_mode ? { tracking_mode, external_provider } : {},
2312
2648
  ...subject_root !== void 0 ? { subject_root } : {},
2313
- ...content_sensitivity ? { content_sensitivity } : {}
2649
+ ...content_sensitivity ? { content_sensitivity } : {},
2650
+ ...push_policy ? { push_policy } : {}
2314
2651
  };
2315
2652
  await writeManifest(targetDir, manifest);
2316
2653
  printSuccess(`Initialized with profile "${profile}" \u2014 ${fileCount} files installed`);
2654
+ if (push_policy === "blocked" || push_policy === "guarded") {
2655
+ try {
2656
+ if (push_policy === "blocked") {
2657
+ const hook = await installPrePushHook(targetDir);
2658
+ if (hook.status === "refused-unreadable-config") {
2659
+ printWarning(
2660
+ "Did not install the pre-push hook: git could not report the current core.hooksPath (unreadable config, or a git too old for `--show-scope`). Installing without knowing whether another hook manager owns that setting could silently disable it. Fix the config and run `spell doctor`."
2661
+ );
2662
+ } else if (hook.status === "refused-foreign-hooks-path") {
2663
+ printWarning(
2664
+ `Did not install the pre-push hook: core.hooksPath is already "${hook.existing}" (${describeConfigScope(hook.scope)}), 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.`
2665
+ );
2666
+ } else {
2667
+ printInfo("Installed a pre-push hook that blocks pushes from this repository.");
2668
+ }
2669
+ const urls = await disablePushUrls(targetDir);
2670
+ const unprotected = urls.filter((u) => u.status === "failed");
2671
+ if (unprotected.length > 0) {
2672
+ printWarning(
2673
+ `Could not disable the push URL for: ${unprotected.map((u) => `${u.remote} (${u.reason ?? "unknown error"})`).join("; ")}. Those remotes are still pushable with \`--no-verify\`. Run \`spell doctor\` \u2014 it lists exactly which remotes are still live.`
2674
+ );
2675
+ }
2676
+ if (urls.length === 0) {
2677
+ printWarning(
2678
+ "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."
2679
+ );
2680
+ } else {
2681
+ const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
2682
+ if (covered.length > 0) {
2683
+ printInfo(`Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
2684
+ }
2685
+ }
2686
+ printInfo("Run `spell unblock-push` from a terminal to undo this.");
2687
+ } else {
2688
+ printInfo(
2689
+ "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."
2690
+ );
2691
+ }
2692
+ } catch (err) {
2693
+ printWarning(
2694
+ `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.`
2695
+ );
2696
+ }
2697
+ }
2317
2698
  let agentsConfigured = false;
2318
2699
  if (hasAgents && !options.profile) {
2319
2700
  console.log();
@@ -2359,7 +2740,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2359
2740
  }
2360
2741
 
2361
2742
  // src/commands/add.ts
2362
- import { join as join5 } from "path";
2743
+ import { join as join6 } from "path";
2363
2744
  async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2364
2745
  let manifest;
2365
2746
  try {
@@ -2394,7 +2775,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2394
2775
  let fileCount = 0;
2395
2776
  const installedFiles = [];
2396
2777
  for (const file of component.files) {
2397
- const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
2778
+ const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
2398
2779
  if (options.dryRun) {
2399
2780
  console.log(` [dry-run] Would copy: ${file}`);
2400
2781
  } else {
@@ -2404,7 +2785,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2404
2785
  fileCount++;
2405
2786
  }
2406
2787
  for (const dir of component.directories ?? []) {
2407
- const srcDirPath = join5(assetsDir, dir);
2788
+ const srcDirPath = join6(assetsDir, dir);
2408
2789
  if (options.dryRun) {
2409
2790
  console.log(` [dry-run] Would copy directory: ${dir}/`);
2410
2791
  } else {
@@ -2434,12 +2815,12 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2434
2815
  }
2435
2816
 
2436
2817
  // src/commands/update.ts
2437
- import { join as join7 } from "path";
2818
+ import { join as join8 } from "path";
2438
2819
 
2439
2820
  // src/modules/hub.ts
2440
2821
  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";
2822
+ import { readdir as readdir3, writeFile as writeFile6 } from "fs/promises";
2823
+ import { join as join7 } from "path";
2443
2824
  var MANIFEST_RETROFITS = [
2444
2825
  {
2445
2826
  field: "role",
@@ -2539,6 +2920,27 @@ var MANIFEST_RETROFITS = [
2539
2920
  }
2540
2921
  return { subject_root: null };
2541
2922
  }
2923
+ },
2924
+ {
2925
+ field: "push_policy",
2926
+ needsRetrofit: (m) => m.push_policy === void 0,
2927
+ // EF-09. Defaults to "open", so an operator who just presses enter keeps
2928
+ // today's behaviour exactly. NOTE: this only records the choice -- unlike
2929
+ // init, the retrofit does not install the hook or disable the push URL,
2930
+ // because an update should not silently start blocking pushes in a repo
2931
+ // someone is mid-workflow in. doctor reports the gap instead.
2932
+ ask: async () => {
2933
+ const push_policy = await select3({
2934
+ message: "Should this repository be allowed to push to a remote?",
2935
+ choices: [
2936
+ { value: "open", name: "Yes \u2014 normal repository" },
2937
+ { value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
2938
+ { value: "blocked", name: "No \u2014 block pushes" }
2939
+ ],
2940
+ default: "open"
2941
+ });
2942
+ return { push_policy };
2943
+ }
2542
2944
  }
2543
2945
  ];
2544
2946
  async function runManifestRetrofits(manifest) {
@@ -2556,9 +2958,9 @@ async function runManifestRetrofits(manifest) {
2556
2958
  return patch;
2557
2959
  }
2558
2960
  async function offerRegistryScaffold(targetDir, businessRoot) {
2559
- const registryPath = join6(targetDir, businessRoot, "registry.json");
2961
+ const registryPath = join7(targetDir, businessRoot, "registry.json");
2560
2962
  if (await fileExists(registryPath)) return;
2561
- const businessRootPath = join6(targetDir, businessRoot);
2963
+ const businessRootPath = join7(targetDir, businessRoot);
2562
2964
  let ventureDirs;
2563
2965
  try {
2564
2966
  const entries = await readdir3(businessRootPath, { withFileTypes: true });
@@ -2596,7 +2998,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
2596
2998
  updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
2597
2999
  ventures
2598
3000
  };
2599
- await writeFile5(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3001
+ await writeFile6(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
2600
3002
  console.log(
2601
3003
  ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
2602
3004
  );
@@ -2689,8 +3091,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2689
3091
  }
2690
3092
  const updatedFiles = [];
2691
3093
  for (const file of component.files) {
2692
- const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
2693
- const targetExists = await fileExists(join7(targetDir, file));
3094
+ const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
3095
+ const targetExists = await fileExists(join8(targetDir, file));
2694
3096
  const preserveExisting = Boolean(component.skipExisting) && targetExists;
2695
3097
  if (component.initOnly && !targetExists) {
2696
3098
  console.log(
@@ -2712,7 +3114,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2712
3114
  }
2713
3115
  }
2714
3116
  for (const dir of component.directories ?? []) {
2715
- const srcDirPath = join7(assetsDir, dir);
3117
+ const srcDirPath = join8(assetsDir, dir);
2716
3118
  if (options.dryRun) {
2717
3119
  console.log(` [dry-run] Would update directory: ${dir}/`);
2718
3120
  } else {
@@ -2767,8 +3169,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2767
3169
  }
2768
3170
 
2769
3171
  // 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";
3172
+ import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
3173
+ import { dirname, join as join9 } from "path";
2772
3174
  import { homedir as homedir2 } from "os";
2773
3175
  import { createRequire } from "module";
2774
3176
  var require2 = createRequire(import.meta.url);
@@ -2782,11 +3184,11 @@ function getFeedUrl() {
2782
3184
  }
2783
3185
  return url;
2784
3186
  }
2785
- var DEFAULT_CACHE_PATH = join8(homedir2(), ".arcane", "version-cache.json");
3187
+ var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
2786
3188
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2787
3189
  async function readCache(cachePath) {
2788
3190
  try {
2789
- const content = await readFile4(cachePath, "utf-8");
3191
+ const content = await readFile5(cachePath, "utf-8");
2790
3192
  const parsed = JSON.parse(content);
2791
3193
  if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
2792
3194
  return null;
@@ -2797,9 +3199,9 @@ async function readCache(cachePath) {
2797
3199
  }
2798
3200
  }
2799
3201
  async function writeCache(cachePath, latest) {
2800
- await mkdir4(dirname(cachePath), { recursive: true });
3202
+ await mkdir5(dirname(cachePath), { recursive: true });
2801
3203
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
2802
- await writeFile6(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3204
+ await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
2803
3205
  }
2804
3206
  async function getLatestVersion(feedUrl) {
2805
3207
  const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
@@ -2911,8 +3313,8 @@ async function runStatus(targetDir, packageVersion) {
2911
3313
  }
2912
3314
 
2913
3315
  // src/commands/uninstall.ts
2914
- import { rm as rm2 } from "fs/promises";
2915
- import { join as join9 } from "path";
3316
+ import { rm as rm3 } from "fs/promises";
3317
+ import { join as join10 } from "path";
2916
3318
  import { confirm as confirm3 } from "@inquirer/prompts";
2917
3319
  async function runUninstall(options, targetDir) {
2918
3320
  let manifest;
@@ -2926,6 +3328,21 @@ async function runUninstall(options, targetDir) {
2926
3328
  }
2927
3329
  throw err;
2928
3330
  }
3331
+ const policy = manifest.push_policy ?? "open";
3332
+ const controlsInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
3333
+ if ((policy === "blocked" || controlsInForce) && !options.dryRun) {
3334
+ const why = policy === "blocked" ? `this repository's push_policy is "blocked"` : `this repository still has Arcane's push block in force (the manifest says "${policy}", but the controls are actually installed)`;
3335
+ console.error(
3336
+ `Refusing to uninstall: ${why}.
3337
+ Uninstalling now would delete .arcane.json while leaving the pre-push hook and the
3338
+ disabled push URLs in place \u2014 the repository could not push, and \`spell unblock-push\`
3339
+ would no longer recognise it.
3340
+
3341
+ Run \`spell unblock-push\` first (interactive), then uninstall.`
3342
+ );
3343
+ process.exit(1);
3344
+ return;
3345
+ }
2929
3346
  const allFiles = manifest.components.flatMap((c) => c.files);
2930
3347
  const totalFiles = allFiles.length;
2931
3348
  if (options.dryRun) {
@@ -2940,6 +3357,11 @@ async function runUninstall(options, targetDir) {
2940
3357
  console.log(`[dry-run] Would strip arcane sections from: CLAUDE.md, AGENTS.md, copilot-instructions.md`);
2941
3358
  console.log(`
2942
3359
  [dry-run] ${totalFiles} file(s) + manifest + agent outputs would be removed.`);
3360
+ if (policy === "blocked" || controlsInForce) {
3361
+ console.log(
3362
+ `[dry-run] BUT the real run would refuse: Arcane's push block is in force. Run \`spell unblock-push\` first.`
3363
+ );
3364
+ }
2943
3365
  return;
2944
3366
  }
2945
3367
  if (!options.yes) {
@@ -2955,24 +3377,24 @@ async function runUninstall(options, targetDir) {
2955
3377
  let removed = 0;
2956
3378
  for (const component of manifest.components) {
2957
3379
  for (const file of component.files) {
2958
- const filePath = join9(targetDir, file);
3380
+ const filePath = join10(targetDir, file);
2959
3381
  try {
2960
- await rm2(filePath, { force: true });
3382
+ await rm3(filePath, { force: true });
2961
3383
  removed++;
2962
3384
  } catch {
2963
3385
  }
2964
3386
  }
2965
3387
  }
2966
- await rm2(join9(targetDir, ".arcane.json"), { force: true });
3388
+ await rm3(join10(targetDir, ".arcane.json"), { force: true });
2967
3389
  const agentDirs = [
2968
3390
  ".github/agents",
2969
3391
  ".arcane/agents",
2970
3392
  ".arcane/generated"
2971
3393
  ];
2972
3394
  for (const dir of agentDirs) {
2973
- await rm2(join9(targetDir, dir), { recursive: true, force: true });
3395
+ await rm3(join10(targetDir, dir), { recursive: true, force: true });
2974
3396
  }
2975
- await rm2(join9(targetDir, ".arcane/agents.yaml"), { force: true });
3397
+ await rm3(join10(targetDir, ".arcane/agents.yaml"), { force: true });
2976
3398
  const markerFiles = [
2977
3399
  "CLAUDE.md",
2978
3400
  "AGENTS.md",
@@ -2986,8 +3408,8 @@ async function runUninstall(options, targetDir) {
2986
3408
  }
2987
3409
 
2988
3410
  // 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";
3411
+ import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3412
+ import { join as join11, dirname as dirname2 } from "path";
2991
3413
  import { execFile as execFile2 } from "child_process";
2992
3414
  import { promisify } from "util";
2993
3415
 
@@ -3075,8 +3497,8 @@ async function checkVSCodeExtension(id, label) {
3075
3497
  const name = `VS Code extension: ${label}`;
3076
3498
  const home = process.env["HOME"] ?? "";
3077
3499
  const extDirs = [
3078
- join10(home, ".vscode", "extensions"),
3079
- join10(home, ".vscode-insiders", "extensions")
3500
+ join11(home, ".vscode", "extensions"),
3501
+ join11(home, ".vscode-insiders", "extensions")
3080
3502
  ];
3081
3503
  for (const dir of extDirs) {
3082
3504
  try {
@@ -3110,13 +3532,13 @@ async function checkVSCodeExtension(id, label) {
3110
3532
  }
3111
3533
  async function checkArcaneManifest(targetDir) {
3112
3534
  const name = "Arcane manifest (.arcane.json)";
3113
- const manifestPath2 = join10(targetDir, ".arcane.json");
3535
+ const manifestPath2 = join11(targetDir, ".arcane.json");
3114
3536
  try {
3115
- await access3(manifestPath2);
3537
+ await access4(manifestPath2);
3116
3538
  } catch {
3117
- const selfHostedManifestPath = join10(targetDir, "src", "assets", ".arcane.json");
3539
+ const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
3118
3540
  try {
3119
- const raw = await readFile5(selfHostedManifestPath, "utf8");
3541
+ const raw = await readFile6(selfHostedManifestPath, "utf8");
3120
3542
  const manifest = JSON.parse(raw);
3121
3543
  if (manifest.selfHosted === true && manifest.tracking_mode === "internal") {
3122
3544
  return {
@@ -3135,7 +3557,7 @@ async function checkArcaneManifest(targetDir) {
3135
3557
  };
3136
3558
  }
3137
3559
  try {
3138
- const raw = await readFile5(manifestPath2, "utf8");
3560
+ const raw = await readFile6(manifestPath2, "utf8");
3139
3561
  const manifest = JSON.parse(raw);
3140
3562
  if (!manifest.version || !Array.isArray(manifest.components)) {
3141
3563
  return {
@@ -3206,9 +3628,9 @@ async function checkSessionContinuity(targetDir) {
3206
3628
  const results = [];
3207
3629
  for (const file of SESSION_CONTINUITY_FILES) {
3208
3630
  const name = `Session file: ${file}`;
3209
- const filePath = join10(targetDir, file);
3631
+ const filePath = join11(targetDir, file);
3210
3632
  try {
3211
- await access3(filePath);
3633
+ await access4(filePath);
3212
3634
  results.push({ name, passed: true, message: "present" });
3213
3635
  } catch {
3214
3636
  results.push({
@@ -3224,18 +3646,69 @@ async function checkSessionContinuity(targetDir) {
3224
3646
  async function fixSessionContinuity(targetDir, assetsDir) {
3225
3647
  const created = [];
3226
3648
  for (const file of SESSION_CONTINUITY_FILES) {
3227
- const destPath = join10(targetDir, file);
3649
+ const destPath = join11(targetDir, file);
3228
3650
  try {
3229
- await access3(destPath);
3651
+ await access4(destPath);
3230
3652
  } catch {
3231
- const srcPath = join10(assetsDir, file);
3232
- await mkdir5(dirname2(destPath), { recursive: true });
3653
+ const srcPath = join11(assetsDir, file);
3654
+ await mkdir6(dirname2(destPath), { recursive: true });
3233
3655
  await fsCopyFile2(srcPath, destPath);
3234
3656
  created.push(file);
3235
3657
  }
3236
3658
  }
3237
3659
  return created;
3238
3660
  }
3661
+ async function checkPushPolicy(targetDir) {
3662
+ const name = "Push policy (EF-09)";
3663
+ let manifest;
3664
+ try {
3665
+ manifest = await readManifest(targetDir);
3666
+ } catch {
3667
+ return { name, passed: true, message: "not initialized \u2014 nothing to check" };
3668
+ }
3669
+ const policy = manifest.push_policy ?? "open";
3670
+ if (policy === "open") {
3671
+ return { name, passed: true, message: "open \u2014 pushes allowed" };
3672
+ }
3673
+ if (policy === "guarded") {
3674
+ const targets = await pushTargets(targetDir);
3675
+ const where = targets.length === 0 ? "none configured" : targets.map(({ remote, urls }) => `${remote} \u2192 ${urls.length > 0 ? urls.join(", ") : "unknown"}`).join("; ");
3676
+ return {
3677
+ name,
3678
+ passed: false,
3679
+ blocking: false,
3680
+ message: `guarded \u2014 no technical control installed. Push targets: ${where}. Confirm those are the remotes you intend before pushing.`
3681
+ };
3682
+ }
3683
+ const hookActive = await isHookEnforced(targetDir);
3684
+ const remotes = await listRemotes(targetDir);
3685
+ const open = await undisabledRemotes(targetDir);
3686
+ if (hookActive && open.length === 0) {
3687
+ if (remotes.length === 0) {
3688
+ return {
3689
+ name,
3690
+ passed: false,
3691
+ blocking: false,
3692
+ 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."
3693
+ };
3694
+ }
3695
+ return {
3696
+ name,
3697
+ passed: true,
3698
+ message: `blocked \u2014 pre-push hook in place and push URL disabled for ${remotes.join(", ")}`
3699
+ };
3700
+ }
3701
+ const missing = [
3702
+ hookActive ? null : "pre-push hook (config or hook file absent)",
3703
+ open.length > 0 ? `push URL still live for: ${open.join(", ")}` : null
3704
+ ].filter(Boolean);
3705
+ return {
3706
+ name,
3707
+ passed: false,
3708
+ blocking: false,
3709
+ message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have.`
3710
+ };
3711
+ }
3239
3712
  async function runDoctor(targetDir, options = {}, assetsDir) {
3240
3713
  console.log("\nspell doctor \u2014 checking your Arcane environment\n");
3241
3714
  const results = await Promise.all([
@@ -3243,7 +3716,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
3243
3716
  checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
3244
3717
  checkArcaneManifest(targetDir),
3245
3718
  Promise.resolve(checkIncidentReleaseGate()),
3246
- checkPullRebase(targetDir)
3719
+ checkPullRebase(targetDir),
3720
+ checkPushPolicy(targetDir)
3247
3721
  ]);
3248
3722
  const sessionResults = await checkSessionContinuity(targetDir);
3249
3723
  results.push(...sessionResults);
@@ -3287,11 +3761,103 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
3287
3761
  }
3288
3762
  }
3289
3763
 
3764
+ // src/commands/unblock-push.ts
3765
+ import { basename, resolve as resolve2 } from "path";
3766
+ import { input as input4 } from "@inquirer/prompts";
3767
+ async function runUnblockPush(targetDir) {
3768
+ let manifest;
3769
+ try {
3770
+ manifest = await readManifest(targetDir);
3771
+ } catch (err) {
3772
+ if (err instanceof ManifestNotFoundError) {
3773
+ console.error('Not initialized. Run "spell init" first.');
3774
+ process.exit(1);
3775
+ return;
3776
+ }
3777
+ throw err;
3778
+ }
3779
+ const policy = manifest.push_policy ?? "open";
3780
+ if (policy === "open") {
3781
+ printInfo("This repository is not push-blocked \u2014 nothing to undo.");
3782
+ return;
3783
+ }
3784
+ if (!process.stdin.isTTY) {
3785
+ console.error(
3786
+ "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."
3787
+ );
3788
+ process.exit(1);
3789
+ return;
3790
+ }
3791
+ const repoName = basename(resolve2(targetDir));
3792
+ if (repoName === "") {
3793
+ console.error(
3794
+ "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
3795
+ );
3796
+ process.exit(1);
3797
+ return;
3798
+ }
3799
+ printWarning(`This repository's push_policy is "${policy}".`);
3800
+ printInfo(
3801
+ "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."
3802
+ );
3803
+ console.log();
3804
+ const typed = await input4({
3805
+ message: `Type the repository name to confirm (${repoName}):`
3806
+ });
3807
+ if (typed.trim() !== repoName) {
3808
+ console.error(`
3809
+ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
3810
+ process.exit(1);
3811
+ return;
3812
+ }
3813
+ await removePrePushHook(targetDir);
3814
+ const results = await restorePushUrls(targetDir);
3815
+ const restored = results.filter((r) => r.status === "restored").map((r) => r.remote);
3816
+ const failed = results.filter((r) => r.status === "failed");
3817
+ const stillBlocked = await blockedRemotes(targetDir);
3818
+ const updated = {
3819
+ ...manifest,
3820
+ push_policy: "open",
3821
+ push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
3822
+ };
3823
+ await writeManifest(targetDir, updated);
3824
+ const hookStillOn = await isHookEnforced(targetDir);
3825
+ const fullyLifted = failed.length === 0 && stillBlocked.length === 0 && !hookStillOn;
3826
+ if (fullyLifted) {
3827
+ printSuccess(`Push unblocked for "${repoName}".`);
3828
+ } else {
3829
+ printWarning(
3830
+ `Push only PARTIALLY unblocked for "${repoName}". The manifest now says open; the repository is not fully open yet. Details below.`
3831
+ );
3832
+ }
3833
+ if (restored.length > 0) {
3834
+ printInfo(`Restored push URL for: ${restored.join(", ")}.`);
3835
+ }
3836
+ if (hookStillOn) {
3837
+ printWarning(
3838
+ "The pre-push hook is still in force. Arcane did not remove it because `core.hooksPath` is not one it owns, or could not be read. Check `git config --show-scope --get core.hooksPath`."
3839
+ );
3840
+ }
3841
+ if (failed.length > 0) {
3842
+ printWarning(
3843
+ `Could not restore the push URL for: ${failed.map((r) => `${r.remote} (${r.reason ?? "unknown error"})`).join("; ")}.`
3844
+ );
3845
+ }
3846
+ if (stillBlocked.length > 0) {
3847
+ printWarning(
3848
+ `These remotes still have a blocked push URL and no recorded original: ${stillBlocked.join(", ")}. Set them yourself with \`git remote set-url --push <name> <url>\` \u2014 until then, pushing to them will fail even though the manifest now says open.`
3849
+ );
3850
+ }
3851
+ printInfo(
3852
+ "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."
3853
+ );
3854
+ }
3855
+
3290
3856
  // src/index.ts
3291
3857
  import { realpathSync } from "fs";
3292
3858
  var require3 = createRequire2(import.meta.url);
3293
3859
  var pkg = require3("../package.json");
3294
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join11(dirname3(fileURLToPath(import.meta.url)), "assets");
3860
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname3(fileURLToPath(import.meta.url)), "assets");
3295
3861
  var program = new Command();
3296
3862
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
3297
3863
  await printWelcome(pkg.version, process.cwd());
@@ -3336,6 +3902,9 @@ program.command("status").description("Show installed components and check for u
3336
3902
  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
3903
  await runUninstall({ yes: opts.yes, dryRun: opts.dryRun }, process.cwd());
3338
3904
  });
3905
+ program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
3906
+ await runUnblockPush(process.cwd());
3907
+ });
3339
3908
  program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
3340
3909
  await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
3341
3910
  });