arcane-cli 1.5.1 → 1.6.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 dirname11, join as join18 } from "path";
9
+ import { dirname as dirname11, join as join19 } from "path";
10
10
 
11
11
  // src/commands/init.ts
12
- import { select as select2, confirm, input as input2 } from "@inquirer/prompts";
13
- import { dirname as dirname4, join as join6 } from "path";
12
+ import { select as select3, confirm as confirm2, input as input3 } from "@inquirer/prompts";
13
+ import { dirname as dirname4, join as join7 } from "path";
14
14
  import chalk2 from "chalk";
15
15
 
16
16
  // src/modules/copier.ts
@@ -140,11 +140,13 @@ var ManifestCorruptError = class extends Error {
140
140
  }
141
141
  };
142
142
  var ManifestInvalidFieldError = class extends Error {
143
+ field;
143
144
  constructor(manifestPath2, field, value) {
144
145
  super(
145
146
  `Manifest at "${manifestPath2}" has an unsupported value for "${field}": ${JSON.stringify(value)}.`
146
147
  );
147
148
  this.name = "ManifestInvalidFieldError";
149
+ this.field = field;
148
150
  }
149
151
  };
150
152
  var MANIFEST_FILE = ".arcane.json";
@@ -152,6 +154,12 @@ var VALID_TRACKING_MODES = ["internal", "external"];
152
154
  var VALID_EXTERNAL_PROVIDERS = ["ado", "github", "jira", "other"];
153
155
  var VALID_CONTENT_SENSITIVITY = ["standard", "sensitive"];
154
156
  var VALID_PUSH_POLICIES = ["open", "guarded", "blocked"];
157
+ var MANIFEST_ENUM_VALUES = {
158
+ tracking_mode: VALID_TRACKING_MODES,
159
+ external_provider: VALID_EXTERNAL_PROVIDERS,
160
+ content_sensitivity: VALID_CONTENT_SENSITIVITY,
161
+ push_policy: VALID_PUSH_POLICIES
162
+ };
155
163
  var VALID_INSTALL_SCOPES = ["repo", "user"];
156
164
  function isValidFanoutPath(file) {
157
165
  return file.length > 0 && !file.startsWith("/") && !/^[a-zA-Z]:/.test(file) && !file.includes("\\") && !file.split("/").includes("..") && !/[\r\n\0]/.test(file);
@@ -181,13 +189,16 @@ function validateSecretsScanExcludePrefixes(value, filePath) {
181
189
  throw new ManifestInvalidFieldError(filePath, "secretsScanExcludePrefixes", value);
182
190
  }
183
191
  }
192
+ function isValidIdeaTags(value) {
193
+ return isRecord(value) && (value["mode"] === "extend" || value["mode"] === "replace") && Array.isArray(value["tags"]) && value["tags"].every((tag) => typeof tag === "string" && tag.trim() !== "");
194
+ }
184
195
  function manifestPath(targetDir) {
185
196
  return path2.join(targetDir, MANIFEST_FILE);
186
197
  }
187
198
  function isRecord(value) {
188
199
  return typeof value === "object" && value !== null && !Array.isArray(value);
189
200
  }
190
- function validateTrackingFields(manifest, filePath) {
201
+ function validateManifestFields(manifest, filePath) {
191
202
  if (manifest.tracking_mode !== void 0 && !VALID_TRACKING_MODES.includes(manifest.tracking_mode)) {
192
203
  throw new ManifestInvalidFieldError(filePath, "tracking_mode", manifest.tracking_mode);
193
204
  }
@@ -222,6 +233,9 @@ function validateTrackingFields(manifest, filePath) {
222
233
  if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
223
234
  throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
224
235
  }
236
+ if (manifest.idea_tags !== void 0 && !isValidIdeaTags(manifest.idea_tags)) {
237
+ throw new ManifestInvalidFieldError(filePath, "idea_tags", manifest.idea_tags);
238
+ }
225
239
  }
226
240
  async function readManifest(targetDir) {
227
241
  const filePath = manifestPath(targetDir);
@@ -240,7 +254,7 @@ async function readManifest(targetDir) {
240
254
  } catch {
241
255
  throw new ManifestCorruptError(filePath);
242
256
  }
243
- validateTrackingFields(manifest, filePath);
257
+ validateManifestFields(manifest, filePath);
244
258
  return manifest;
245
259
  }
246
260
  async function resolveSecretsScanExcludePrefixes(targetDir) {
@@ -2646,10 +2660,10 @@ async function runAgentsList(targetDir, scope = "repo") {
2646
2660
  console.log();
2647
2661
  }
2648
2662
 
2649
- // src/modules/push-safety.ts
2650
- import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm4, readdir as readdir5 } from "fs/promises";
2651
- import { constants } from "fs";
2652
- import { join as join5, resolve as resolve2, dirname as dirname3, basename } from "path";
2663
+ // src/modules/hub.ts
2664
+ import { confirm, select as select2, input as input2 } from "@inquirer/prompts";
2665
+ import { readdir as readdir5, writeFile as writeFile7 } from "fs/promises";
2666
+ import { join as join5 } from "path";
2653
2667
 
2654
2668
  // src/modules/git.ts
2655
2669
  import { execFile } from "child_process";
@@ -2869,7 +2883,371 @@ async function ensureLocalPullRebase(cwd) {
2869
2883
  }
2870
2884
  }
2871
2885
 
2886
+ // src/modules/ado-context.ts
2887
+ function remoteUrlHost(url) {
2888
+ const trimmed = url.trim();
2889
+ if (trimmed.includes("://")) {
2890
+ try {
2891
+ return new URL(trimmed).hostname.toLowerCase() || void 0;
2892
+ } catch {
2893
+ return void 0;
2894
+ }
2895
+ }
2896
+ const scp = /^(?:[^@/\s]+@)?([^:/\s]+):/.exec(trimmed);
2897
+ if (!scp || /^[a-zA-Z]$/.test(scp[1])) return void 0;
2898
+ return scp[1].toLowerCase();
2899
+ }
2900
+ function isAdoRemoteUrl(url) {
2901
+ const host = remoteUrlHost(url);
2902
+ if (host === void 0) return false;
2903
+ return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
2904
+ }
2905
+ async function detectAdoContext(targetDir) {
2906
+ let stdout;
2907
+ try {
2908
+ ({ stdout } = await runGit(targetDir, ["config", "--get-regexp", "^remote\\..*\\.url$"]));
2909
+ } catch {
2910
+ return false;
2911
+ }
2912
+ return stdout.split("\n").map((line) => line.slice(line.indexOf(" ") + 1)).some((url) => url.trim() !== "" && isAdoRemoteUrl(url));
2913
+ }
2914
+
2915
+ // src/modules/hub.ts
2916
+ async function askTrackingQuestions(targetDir) {
2917
+ const ado = targetDir !== void 0 && await detectAdoContext(targetDir);
2918
+ const tracking_mode = await select2({
2919
+ message: "How will work be tracked in this repo?",
2920
+ choices: [
2921
+ {
2922
+ value: "internal",
2923
+ name: "Track work in this repo (TODO.md / PRDs)",
2924
+ description: "Spells keep TODOs, PRDs and stories in this repository's own files."
2925
+ },
2926
+ {
2927
+ value: "external",
2928
+ name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)",
2929
+ description: "Spells tie PRDs, commits and PRs to items in the tracker you pick next."
2930
+ }
2931
+ ],
2932
+ ...ado ? { default: "external" } : {}
2933
+ });
2934
+ if (tracking_mode !== "external") return { tracking_mode, external_provider: null };
2935
+ const external_provider = await select2({
2936
+ message: "Which external tracker?",
2937
+ choices: [
2938
+ {
2939
+ value: "ado",
2940
+ name: "Azure DevOps",
2941
+ description: "Each PRD needs an ADO work item ID; plans follow your process template's item types."
2942
+ },
2943
+ {
2944
+ value: "github",
2945
+ name: "GitHub Issues",
2946
+ description: "Uses the gh CLI; an issue number is optional and linked from commits and PRs when known."
2947
+ },
2948
+ {
2949
+ value: "jira",
2950
+ name: "Jira",
2951
+ description: "No Jira automation yet: spells note linking as a TODO and keep planning in this repo."
2952
+ },
2953
+ {
2954
+ value: "other",
2955
+ name: "Other",
2956
+ description: "No automation: spells note linking as a TODO and keep planning in this repo."
2957
+ }
2958
+ ],
2959
+ ...ado ? { default: "ado" } : {}
2960
+ });
2961
+ return { tracking_mode, external_provider };
2962
+ }
2963
+ var MANIFEST_RETROFITS = [
2964
+ {
2965
+ field: "role",
2966
+ flag: "--role <hub|consumer>",
2967
+ needsRetrofit: (m) => m.role === void 0,
2968
+ ask: async () => {
2969
+ const isHub = await confirm({
2970
+ message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
2971
+ default: false
2972
+ });
2973
+ return { role: isHub ? "hub" : "consumer" };
2974
+ }
2975
+ },
2976
+ {
2977
+ field: "tracking_mode",
2978
+ flag: "--tracking-mode <internal|external>",
2979
+ needsRetrofit: (m) => m.tracking_mode === void 0,
2980
+ // Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
2981
+ // profiles get a silent default, full/lite get asked -- through the same
2982
+ // askTrackingQuestions init uses, so the two cannot drift.
2983
+ ask: async (manifest, targetDir) => {
2984
+ if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
2985
+ return { tracking_mode: "internal", external_provider: null };
2986
+ }
2987
+ return askTrackingQuestions(targetDir);
2988
+ }
2989
+ },
2990
+ {
2991
+ field: "content_sensitivity",
2992
+ flag: "--content-sensitivity <standard|sensitive>",
2993
+ needsRetrofit: (m) => m.content_sensitivity === void 0,
2994
+ // EF-12. Asked for every profile -- a code repo can hold sensitive records
2995
+ // too -- and defaults to "standard", so an operator who just presses enter
2996
+ // keeps today's behaviour exactly.
2997
+ ask: async () => {
2998
+ const content_sensitivity = await select2({
2999
+ message: "How should agents treat this repository's contents?",
3000
+ choices: [
3001
+ {
3002
+ value: "standard",
3003
+ name: "Standard \u2014 agents may quote contents in journals and decisions"
3004
+ },
3005
+ {
3006
+ value: "sensitive",
3007
+ name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
3008
+ }
3009
+ ],
3010
+ default: "standard"
3011
+ });
3012
+ return { content_sensitivity };
3013
+ }
3014
+ },
3015
+ {
3016
+ field: "subject_root",
3017
+ flag: "--subject-root <path>",
3018
+ // EF-07. Only the docs profile is asked: other profiles describe code or a
3019
+ // venture portfolio, where "what is this repo about" is already answered.
3020
+ // A docs install that legitimately holds several subjects answers
3021
+ // "portfolio" and stays unset -- so this retrofit deliberately does NOT
3022
+ // re-ask on every update. It is gated on the field being absent AND the
3023
+ // profile being docs, and once answered (either way) it never fires again,
3024
+ // because "portfolio" writes an explicit empty marker.
3025
+ needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
3026
+ ask: async () => {
3027
+ const shape = await select2({
3028
+ message: "What does this repository hold?",
3029
+ choices: [
3030
+ {
3031
+ value: "root",
3032
+ name: "One subject, at the repository root (documents sit alongside Arcane's files)"
3033
+ },
3034
+ { value: "subdir", name: "One subject, in its own directory" },
3035
+ { value: "portfolio", name: "Several subjects or ventures" }
3036
+ ]
3037
+ });
3038
+ if (shape === "root") return { subject_root: "." };
3039
+ if (shape === "subdir") {
3040
+ const answer = await input2({
3041
+ message: "Directory holding the subject's documents:",
3042
+ default: "docs",
3043
+ validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
3044
+ });
3045
+ return { subject_root: answer.trim() || "docs" };
3046
+ }
3047
+ return { subject_root: null };
3048
+ }
3049
+ },
3050
+ {
3051
+ field: "push_policy",
3052
+ flag: "--push-policy <open|guarded|blocked>",
3053
+ needsRetrofit: (m) => m.push_policy === void 0,
3054
+ // EF-09. Defaults to "open", so an operator who just presses enter keeps
3055
+ // today's behaviour exactly. NOTE: this only records the choice -- unlike
3056
+ // init, the retrofit does not install the hook or disable the push URL,
3057
+ // because an update should not silently start blocking pushes in a repo
3058
+ // someone is mid-workflow in (ARC-034 decision 7). ARC-049 decision 2:
3059
+ // update prints pushPolicyNotice() instead, naming `spell block-push`.
3060
+ ask: async () => {
3061
+ const push_policy = await select2({
3062
+ message: "Should this repository be allowed to push to a remote?",
3063
+ choices: [
3064
+ { value: "open", name: "Yes \u2014 normal repository" },
3065
+ { value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
3066
+ { value: "blocked", name: "No \u2014 block pushes" }
3067
+ ],
3068
+ default: "open"
3069
+ });
3070
+ return { push_policy };
3071
+ }
3072
+ }
3073
+ ];
3074
+ function pushPolicyNotice(policy) {
3075
+ if (policy === "blocked") {
3076
+ return [
3077
+ "",
3078
+ ' ! Required action: push_policy is now recorded as "blocked", but it is NOT enforced yet.',
3079
+ " `spell update` never installs push controls. Pushes from this repository still work",
3080
+ " until you run:",
3081
+ "",
3082
+ " spell block-push",
3083
+ ""
3084
+ ];
3085
+ }
3086
+ if (policy === "guarded") {
3087
+ return [
3088
+ "",
3089
+ ' push_policy is now recorded as "guarded". Nothing is installed for it: `spell doctor`',
3090
+ " reports it, and push-performing spells state the policy and ask before pushing.",
3091
+ ""
3092
+ ];
3093
+ }
3094
+ return [];
3095
+ }
3096
+ async function runManifestRetrofits(manifest, targetDir) {
3097
+ const applicable = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
3098
+ if (applicable.length === 0) return {};
3099
+ console.log();
3100
+ console.log(
3101
+ `This install predates ${applicable.length} manifest field${applicable.length === 1 ? "" : "s"} -- a couple of quick questions:`
3102
+ );
3103
+ let patch = {};
3104
+ for (const retrofit of applicable) {
3105
+ const answer = await retrofit.ask({ ...manifest, ...patch }, targetDir);
3106
+ patch = { ...patch, ...answer };
3107
+ }
3108
+ return patch;
3109
+ }
3110
+ function manifestFlagLine(manifest) {
3111
+ const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
3112
+ if (pending.length === 0) return void 0;
3113
+ const lines = [`spell update ${pending.map((r) => r.flag).join(" ")}`];
3114
+ if (pending.some((r) => r.field === "tracking_mode")) {
3115
+ lines.push("(--tracking-mode external also needs --external-provider <ado|github|jira|other>)");
3116
+ }
3117
+ return lines;
3118
+ }
3119
+ var ManifestFlagError = class extends Error {
3120
+ constructor(message) {
3121
+ super(message);
3122
+ this.name = "ManifestFlagError";
3123
+ }
3124
+ };
3125
+ var FLAG_NAMES = {
3126
+ role: "--role",
3127
+ tracking_mode: "--tracking-mode",
3128
+ external_provider: "--external-provider",
3129
+ content_sensitivity: "--content-sensitivity",
3130
+ subject_root: "--subject-root",
3131
+ push_policy: "--push-policy"
3132
+ };
3133
+ var PUSH_POLICY_STRICTNESS = { open: 0, guarded: 1, blocked: 2 };
3134
+ function hasManifestFlags(flags) {
3135
+ return flags !== void 0 && Object.values(flags).some((v) => v !== void 0);
3136
+ }
3137
+ function resolveManifestFlags(flags, current, manifestPath2) {
3138
+ if (!hasManifestFlags(flags)) return {};
3139
+ const requested = {};
3140
+ if (flags.role !== void 0) requested.role = flags.role;
3141
+ if (flags.trackingMode !== void 0) requested.tracking_mode = flags.trackingMode;
3142
+ if (flags.externalProvider !== void 0) requested.external_provider = flags.externalProvider;
3143
+ if (flags.contentSensitivity !== void 0) requested.content_sensitivity = flags.contentSensitivity;
3144
+ if (flags.subjectRoot !== void 0) requested.subject_root = flags.subjectRoot;
3145
+ if (flags.pushPolicy !== void 0) requested.push_policy = flags.pushPolicy;
3146
+ const invalid = (error) => {
3147
+ const allowed = error.field === "role" ? ["hub", "consumer"] : MANIFEST_ENUM_VALUES[error.field];
3148
+ const hint = allowed ? ` Valid values: ${allowed.join(", ")}.` : "";
3149
+ return new ManifestFlagError(`Invalid ${FLAG_NAMES[error.field] ?? error.field}: ${error.message}${hint}`);
3150
+ };
3151
+ if (requested.role !== void 0 && requested.role !== "hub" && requested.role !== "consumer") {
3152
+ throw invalid(new ManifestInvalidFieldError(manifestPath2, "role", requested.role));
3153
+ }
3154
+ try {
3155
+ validateManifestFields(requested, manifestPath2);
3156
+ } catch (error) {
3157
+ if (error instanceof ManifestInvalidFieldError) throw invalid(error);
3158
+ throw error;
3159
+ }
3160
+ if (requested.external_provider !== void 0 && requested.tracking_mode !== "external") {
3161
+ throw new ManifestFlagError("--external-provider applies only together with --tracking-mode external.");
3162
+ }
3163
+ if (requested.tracking_mode === "external" && requested.external_provider === void 0) {
3164
+ throw new ManifestFlagError(
3165
+ "--tracking-mode external needs --external-provider <ado|github|jira|other> as well."
3166
+ );
3167
+ }
3168
+ if (requested.tracking_mode === "internal") requested.external_provider = null;
3169
+ const patch = {};
3170
+ for (const field of Object.keys(FLAG_NAMES)) {
3171
+ if (!(field in requested)) continue;
3172
+ const value = requested[field];
3173
+ const recorded = current[field];
3174
+ if (recorded === void 0) {
3175
+ patch[field] = value;
3176
+ continue;
3177
+ }
3178
+ if (field === "push_policy") {
3179
+ const from = PUSH_POLICY_STRICTNESS[recorded];
3180
+ const to = PUSH_POLICY_STRICTNESS[value];
3181
+ if (to < from) {
3182
+ throw new ManifestFlagError(
3183
+ `--push-policy ${String(value)} would loosen this repository's recorded push_policy "${String(recorded)}". No flag can loosen it: run \`spell unblock-push\` from an interactive terminal.`
3184
+ );
3185
+ }
3186
+ if (to > from) patch[field] = value;
3187
+ continue;
3188
+ }
3189
+ if (recorded === value) continue;
3190
+ const flag = field === "external_provider" && flags.externalProvider === void 0 ? "--tracking-mode" : FLAG_NAMES[field];
3191
+ throw new ManifestFlagError(
3192
+ `${flag} conflicts with the recorded ${field}: ${JSON.stringify(recorded)}. A flag only answers a question that has no recorded answer; change .arcane.json deliberately if the recorded value is wrong.`
3193
+ );
3194
+ }
3195
+ return patch;
3196
+ }
3197
+ function describeFlagPatch(patch) {
3198
+ return Object.entries(patch).filter(([field]) => field in FLAG_NAMES).map(([field, value]) => `${field}: ${JSON.stringify(value)}`);
3199
+ }
3200
+ async function offerRegistryScaffold(targetDir, businessRoot) {
3201
+ const registryPath = join5(targetDir, businessRoot, "registry.json");
3202
+ if (await fileExists(registryPath)) return;
3203
+ const businessRootPath = join5(targetDir, businessRoot);
3204
+ let ventureDirs;
3205
+ try {
3206
+ const entries = await readdir5(businessRootPath, { withFileTypes: true });
3207
+ ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
3208
+ } catch {
3209
+ return;
3210
+ }
3211
+ if (ventureDirs.length === 0) return;
3212
+ console.log();
3213
+ const scaffold = await confirm({
3214
+ message: `Found ${ventureDirs.length} folder${ventureDirs.length === 1 ? "" : "s"} under ${businessRoot}/ -- scaffold ${businessRoot}/registry.json from them?`,
3215
+ default: true
3216
+ });
3217
+ if (!scaffold) return;
3218
+ const ventures = {};
3219
+ for (const slug of ventureDirs) {
3220
+ const include = await confirm({ message: ` Include "${slug}"?`, default: true });
3221
+ if (!include) continue;
3222
+ ventures[slug] = {
3223
+ name: slug,
3224
+ aliases: [],
3225
+ status: "active",
3226
+ visibility: "private",
3227
+ ownership: "llc",
3228
+ tracking: "none",
3229
+ repos: []
3230
+ };
3231
+ }
3232
+ if (Object.keys(ventures).length === 0) {
3233
+ console.log(" No ventures selected -- registry not created.");
3234
+ return;
3235
+ }
3236
+ const registry = {
3237
+ _comment: "Hub-owned venture registry. Private. Never installed or modified by spell update. Seeded by the retrofit wizard -- review and enrich (aliases, ownership, tracking, clones) before relying on it.",
3238
+ updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
3239
+ ventures
3240
+ };
3241
+ await writeFile7(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3242
+ console.log(
3243
+ ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
3244
+ );
3245
+ }
3246
+
2872
3247
  // src/modules/push-safety.ts
3248
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile8, chmod, access as access3, rm as rm4, readdir as readdir6 } from "fs/promises";
3249
+ import { constants } from "fs";
3250
+ import { join as join6, resolve as resolve2, dirname as dirname3, basename } from "path";
2873
3251
  var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
2874
3252
  var NO_PUSH_URL_MARKER = (remote) => `remote.${remote}.arcaneHadNoPushUrl`;
2875
3253
  var ARCANE_HOOKS_DIR = ".arcane/hooks";
@@ -2960,10 +3338,10 @@ async function repositoryRoot(cwd) {
2960
3338
  return cwd;
2961
3339
  }
2962
3340
  async function hookFilePath(cwd, hookName = "pre-push") {
2963
- return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
3341
+ return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2964
3342
  }
2965
3343
  async function hooksPathValue(cwd) {
2966
- return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
3344
+ return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2967
3345
  }
2968
3346
  async function installHook(cwd, hookName, body) {
2969
3347
  const root = await repositoryRoot(cwd);
@@ -2982,12 +3360,12 @@ async function installHook(cwd, hookName, body) {
2982
3360
  return { status: "refused-default-hooks", hooks: displaced };
2983
3361
  }
2984
3362
  }
2985
- const hookPath = join5(root, ARCANE_HOOKS_DIR, hookName);
3363
+ const hookPath = join6(root, ARCANE_HOOKS_DIR, hookName);
2986
3364
  if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
2987
3365
  return { status: "already-ours" };
2988
3366
  }
2989
- await mkdir5(join5(root, ARCANE_HOOKS_DIR), { recursive: true });
2990
- await writeFile7(hookPath, body, "utf-8");
3367
+ await mkdir5(join6(root, ARCANE_HOOKS_DIR), { recursive: true });
3368
+ await writeFile8(hookPath, body, "utf-8");
2991
3369
  await chmod(hookPath, 493);
2992
3370
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2993
3371
  return { status: "installed", path: hookPath };
@@ -3031,12 +3409,12 @@ async function defaultHooks(cwd) {
3031
3409
  let hooksDir;
3032
3410
  try {
3033
3411
  const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
3034
- hooksDir = join5(resolve2(cwd, stdout.trim()), "hooks");
3412
+ hooksDir = join6(resolve2(cwd, stdout.trim()), "hooks");
3035
3413
  } catch {
3036
3414
  return [];
3037
3415
  }
3038
3416
  try {
3039
- return (await readdir5(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
3417
+ return (await readdir6(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
3040
3418
  } catch {
3041
3419
  return [];
3042
3420
  }
@@ -3067,10 +3445,10 @@ async function removeHook(cwd, hookName) {
3067
3445
  await rm4(await hookFilePath(cwd, hookName), { force: true });
3068
3446
  } catch {
3069
3447
  }
3070
- const hooksDir = join5(root, ARCANE_HOOKS_DIR);
3448
+ const hooksDir = join6(root, ARCANE_HOOKS_DIR);
3071
3449
  let remaining = [];
3072
3450
  try {
3073
- remaining = await readdir5(hooksDir);
3451
+ remaining = await readdir6(hooksDir);
3074
3452
  } catch {
3075
3453
  remaining = [];
3076
3454
  }
@@ -3290,6 +3668,60 @@ function describeError(error) {
3290
3668
  const message = error instanceof Error ? error.message : String(error);
3291
3669
  return message.split("\n")[0].trim();
3292
3670
  }
3671
+ function hookInForce(outcome) {
3672
+ return outcome.status === "installed" || outcome.status === "already-ours";
3673
+ }
3674
+ async function applyBlockedPushControls(cwd, options = {}) {
3675
+ const messages = [];
3676
+ const say = (level, text) => {
3677
+ const message = { level, text };
3678
+ messages.push(message);
3679
+ options.emit?.(message);
3680
+ };
3681
+ const hook = await installPrePushHook(cwd);
3682
+ if (hook.status === "refused-unreadable-config") {
3683
+ say(
3684
+ "warning",
3685
+ "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`."
3686
+ );
3687
+ } else if (hook.status === "refused-default-hooks") {
3688
+ say(
3689
+ "warning",
3690
+ `Did not install the pre-push hook: this repository has hooks in git's default directory (${hook.hooks.join(", ")}). Setting core.hooksPath would silently stop them running. Move them under \`${ARCANE_HOOKS_DIR}\` yourself and re-run, or add the push guard to your existing pre-push hook.`
3691
+ );
3692
+ } else if (hook.status === "refused-foreign-hooks-path") {
3693
+ say(
3694
+ "warning",
3695
+ `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.`
3696
+ );
3697
+ } else {
3698
+ say("info", "Installed a pre-push hook that blocks pushes from this repository.");
3699
+ }
3700
+ if (options.stopOnHookRefusal === true && !hookInForce(hook)) {
3701
+ return { hook, urls: [], stoppedAtHook: true, messages };
3702
+ }
3703
+ const urls = await disablePushUrls(cwd);
3704
+ const unprotected = urls.filter((u) => u.status === "failed");
3705
+ if (unprotected.length > 0) {
3706
+ say(
3707
+ "warning",
3708
+ `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.`
3709
+ );
3710
+ }
3711
+ if (urls.length === 0) {
3712
+ say(
3713
+ "warning",
3714
+ "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."
3715
+ );
3716
+ } else {
3717
+ const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
3718
+ if (covered.length > 0) {
3719
+ say("info", `Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
3720
+ }
3721
+ }
3722
+ say("info", "Run `spell unblock-push` from a terminal to undo this.");
3723
+ return { hook, urls, stoppedAtHook: false, messages };
3724
+ }
3293
3725
 
3294
3726
  // src/modules/secrets-scan.ts
3295
3727
  var SECRETS_RULES = [
@@ -3399,6 +3831,13 @@ function buildProfileDescription(components, profile) {
3399
3831
  }
3400
3832
  async function runInit(options, targetDir, assetsDir, packageVersion) {
3401
3833
  if (options.user) {
3834
+ if (hasManifestFlags(options.manifestFlags)) {
3835
+ console.error(
3836
+ "\n \u2717 The manifest-question flags describe a repository; the user tier has none of those fields.\n"
3837
+ );
3838
+ process.exit(1);
3839
+ return;
3840
+ }
3402
3841
  await runInitUser(options, targetDir, assetsDir, packageVersion);
3403
3842
  return;
3404
3843
  }
@@ -3407,12 +3846,26 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3407
3846
  `Invalid profile "${options.profile}". Valid options: ${VALID_PROFILES.join(", ")}`
3408
3847
  );
3409
3848
  }
3849
+ let flags = {};
3850
+ try {
3851
+ flags = resolveManifestFlags(options.manifestFlags, {}, join7(targetDir, ".arcane.json"));
3852
+ } catch (err) {
3853
+ if (!(err instanceof ManifestFlagError)) throw err;
3854
+ console.error(`
3855
+ \u2717 ${err.message}
3856
+ `);
3857
+ process.exit(1);
3858
+ return;
3859
+ }
3410
3860
  try {
3411
3861
  await readManifest(targetDir);
3412
3862
  console.log(chalk2.hex("#a855f7")(`
3413
3863
  \u2726 Arcane v${packageVersion}
3414
3864
  `));
3415
3865
  console.log('Already initialized. Run "spell update" to update existing files.');
3866
+ if (hasManifestFlags(options.manifestFlags)) {
3867
+ console.log("Pass the same manifest-question flags to `spell update` to record them here.");
3868
+ }
3416
3869
  return;
3417
3870
  } catch (err) {
3418
3871
  if (!(err instanceof ManifestNotFoundError)) throw err;
@@ -3444,7 +3897,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3444
3897
  );
3445
3898
  printInfo("Running spell init will add new files to your working tree.");
3446
3899
  console.log();
3447
- const continueAnyway = await confirm({
3900
+ const continueAnyway = await confirm2({
3448
3901
  message: "Continue anyway?",
3449
3902
  default: true
3450
3903
  });
@@ -3465,7 +3918,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3465
3918
  let profile = options.profile;
3466
3919
  if (!profile) {
3467
3920
  const profiles = listProfiles();
3468
- profile = await select2({
3921
+ profile = await select3({
3469
3922
  message: "Select an installation profile:\n",
3470
3923
  choices: profiles.map((p) => {
3471
3924
  const profileComponents2 = getProfile(p.id);
@@ -3490,7 +3943,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3490
3943
  }
3491
3944
  if (storeVersion !== void 0) {
3492
3945
  console.log();
3493
- const useTier = await confirm({
3946
+ const useTier = await confirm2({
3494
3947
  message: `This machine has an Arcane user tier (v${storeVersion}). Use it for this repository's spells instead of installing copies here?`,
3495
3948
  default: preferred === "user"
3496
3949
  });
@@ -3513,7 +3966,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3513
3966
  console.log(` ${previewParts.join(" \xB7 ")}`);
3514
3967
  console.log();
3515
3968
  if (!options.dryRun && !options.profile) {
3516
- const proceed = await confirm({
3969
+ const proceed = await confirm2({
3517
3970
  message: "Proceed with installation?",
3518
3971
  default: true
3519
3972
  });
@@ -3528,7 +3981,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3528
3981
  const installedFiles = [];
3529
3982
  const fileHashes = {};
3530
3983
  for (const file of component.files) {
3531
- const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
3984
+ const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
3532
3985
  if (options.dryRun) {
3533
3986
  printDryRun(`Would copy: ${file}`);
3534
3987
  } else {
@@ -3545,7 +3998,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3545
3998
  fileCount++;
3546
3999
  }
3547
4000
  for (const dir of component.directories ?? []) {
3548
- const srcDirPath = join6(assetsDir, dir);
4001
+ const srcDirPath = join7(assetsDir, dir);
3549
4002
  if (options.dryRun) {
3550
4003
  printDryRun(`Would copy directory: ${dir}/`);
3551
4004
  } else {
@@ -3574,7 +4027,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3574
4027
  if (options.dryRun) {
3575
4028
  if (hasAgents) {
3576
4029
  const agentFiles = await import("fs").then(
3577
- (fsModule) => fsModule.promises.readdir(join6(assetsDir, "agents"))
4030
+ (fsModule) => fsModule.promises.readdir(join7(assetsDir, "agents"))
3578
4031
  );
3579
4032
  const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
3580
4033
  printDryRun("Would run agent setup (interactive):");
@@ -3586,10 +4039,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3586
4039
  printDryRun(`Would initialize with profile "${profile}" \u2014 ${fileCount} files`);
3587
4040
  return;
3588
4041
  }
3589
- let role;
3590
- if (!options.profile) {
4042
+ let role = flags.role;
4043
+ if (role === void 0 && !options.profile) {
3591
4044
  console.log();
3592
- const isHub = await confirm({
4045
+ const isHub = await confirm2({
3593
4046
  message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
3594
4047
  default: false
3595
4048
  });
@@ -3597,36 +4050,20 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3597
4050
  }
3598
4051
  let tracking_mode;
3599
4052
  let external_provider;
3600
- if (profile === "governance-only" || profile === "methodology" || profile === "docs") {
4053
+ if (flags.tracking_mode !== void 0) {
4054
+ tracking_mode = flags.tracking_mode;
4055
+ external_provider = flags.external_provider ?? null;
4056
+ } else if (profile === "governance-only" || profile === "methodology" || profile === "docs") {
3601
4057
  tracking_mode = "internal";
3602
4058
  external_provider = null;
3603
4059
  } else if (!options.profile) {
3604
4060
  console.log();
3605
- tracking_mode = await select2({
3606
- message: "How will work be tracked in this repo?",
3607
- choices: [
3608
- { value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
3609
- { value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
3610
- ]
3611
- });
3612
- if (tracking_mode === "external") {
3613
- external_provider = await select2({
3614
- message: "Which external tracker?",
3615
- choices: [
3616
- { value: "ado", name: "Azure DevOps" },
3617
- { value: "github", name: "GitHub Issues" },
3618
- { value: "jira", name: "Jira" },
3619
- { value: "other", name: "Other" }
3620
- ]
3621
- });
3622
- } else {
3623
- external_provider = null;
3624
- }
4061
+ ({ tracking_mode, external_provider } = await askTrackingQuestions(targetDir));
3625
4062
  }
3626
- let subject_root;
3627
- if (profile === "docs" && !options.profile) {
4063
+ let subject_root = flags.subject_root;
4064
+ if (subject_root === void 0 && profile === "docs" && !options.profile) {
3628
4065
  console.log();
3629
- const shape = await select2({
4066
+ const shape = await select3({
3630
4067
  message: "What does this repository hold?",
3631
4068
  choices: [
3632
4069
  {
@@ -3640,7 +4077,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3640
4077
  if (shape === "root") {
3641
4078
  subject_root = ".";
3642
4079
  } else if (shape === "subdir") {
3643
- const answer = await input2({
4080
+ const answer = await input3({
3644
4081
  message: "Directory holding the subject's documents:",
3645
4082
  default: "docs",
3646
4083
  validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
@@ -3650,10 +4087,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3650
4087
  subject_root = null;
3651
4088
  }
3652
4089
  }
3653
- let content_sensitivity;
3654
- if (!options.profile) {
4090
+ let content_sensitivity = flags.content_sensitivity;
4091
+ if (content_sensitivity === void 0 && !options.profile) {
3655
4092
  console.log();
3656
- content_sensitivity = await select2({
4093
+ content_sensitivity = await select3({
3657
4094
  message: "How should agents treat this repository's contents?",
3658
4095
  choices: [
3659
4096
  { value: "standard", name: "Standard \u2014 agents may quote contents in journals and decisions" },
@@ -3665,10 +4102,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3665
4102
  default: "standard"
3666
4103
  });
3667
4104
  }
3668
- let push_policy;
3669
- if (!options.profile) {
4105
+ let push_policy = flags.push_policy;
4106
+ if (push_policy === void 0 && !options.profile) {
3670
4107
  console.log();
3671
- push_policy = await select2({
4108
+ push_policy = await select3({
3672
4109
  message: "Should this repository be allowed to push to a remote?",
3673
4110
  choices: [
3674
4111
  { value: "open", name: "Yes \u2014 normal repository" },
@@ -3695,43 +4132,23 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3695
4132
  };
3696
4133
  await writeManifest(targetDir, manifest);
3697
4134
  printSuccess(`Initialized with profile "${profile}" \u2014 ${fileCount} files installed`);
4135
+ const flagLine = options.profile ? manifestFlagLine(manifest) : void 0;
4136
+ if (flagLine) {
4137
+ printInfo("Some setup questions were left unanswered. Answer them without a prompt with:");
4138
+ for (const line of flagLine) printInfo(` ${line}`);
4139
+ }
3698
4140
  if (push_policy === "blocked" || push_policy === "guarded") {
3699
4141
  try {
3700
4142
  if (push_policy === "blocked") {
3701
- const hook = await installPrePushHook(targetDir);
3702
- if (hook.status === "refused-unreadable-config") {
3703
- printWarning(
3704
- "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`."
3705
- );
3706
- } else if (hook.status === "refused-default-hooks") {
3707
- printWarning(
3708
- `Did not install the pre-push hook: this repository has hooks in git's default directory (${hook.hooks.join(", ")}). Setting core.hooksPath would silently stop them running. Move them under \`${ARCANE_HOOKS_DIR}\` yourself and re-run, or add the push guard to your existing pre-push hook.`
3709
- );
3710
- } else if (hook.status === "refused-foreign-hooks-path") {
3711
- printWarning(
3712
- `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.`
3713
- );
3714
- } else {
3715
- printInfo("Installed a pre-push hook that blocks pushes from this repository.");
3716
- }
3717
- const urls = await disablePushUrls(targetDir);
3718
- const unprotected = urls.filter((u) => u.status === "failed");
3719
- if (unprotected.length > 0) {
3720
- printWarning(
3721
- `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.`
3722
- );
3723
- }
3724
- if (urls.length === 0) {
4143
+ const controls = await applyBlockedPushControls(targetDir, {
4144
+ emit: (m) => m.level === "warning" ? printWarning(m.text) : printInfo(m.text)
4145
+ });
4146
+ if (!hookInForce(controls.hook) || controls.urls.some((u) => u.status === "failed")) {
3725
4147
  printWarning(
3726
- "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."
4148
+ 'push_policy is recorded as "blocked", but the block is not fully in force. Resolve the cause above, then run `spell block-push`; `spell doctor` shows exactly what is missing.'
3727
4149
  );
3728
- } else {
3729
- const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
3730
- if (covered.length > 0) {
3731
- printInfo(`Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
3732
- }
4150
+ process.exitCode = 1;
3733
4151
  }
3734
- printInfo("Run `spell unblock-push` from a terminal to undo this.");
3735
4152
  } else {
3736
4153
  printInfo(
3737
4154
  "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."
@@ -3777,7 +4194,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3777
4194
  let agentsConfigured = false;
3778
4195
  if (hasAgents && !options.profile) {
3779
4196
  console.log();
3780
- const setupAgents = await confirm({
4197
+ const setupAgents = await confirm2({
3781
4198
  message: "\u{1F916} Set up your agent team? (names, roles, AI tool configs)",
3782
4199
  default: true
3783
4200
  });
@@ -3823,6 +4240,11 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3823
4240
  '"--profile" does not apply to "--user": the user tier always installs every spell and nothing else.'
3824
4241
  );
3825
4242
  }
4243
+ if (hasManifestFlags(options.manifestFlags)) {
4244
+ throw new Error(
4245
+ 'The manifest-question flags do not apply to "--user": they describe a repository, and the user tier is not one.'
4246
+ );
4247
+ }
3826
4248
  try {
3827
4249
  await readManifest(storeRoot);
3828
4250
  console.log(chalk2.hex("#a855f7")(`
@@ -3856,7 +4278,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3856
4278
  for (const id of spellIds) {
3857
4279
  for (const client of ["codex", "claude"]) {
3858
4280
  const relativePath = USER_FANOUT_PATHS[client](id);
3859
- if (await fileExists(join6(homeDir, relativePath))) {
4281
+ if (await fileExists(join7(homeDir, relativePath))) {
3860
4282
  printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
3861
4283
  } else {
3862
4284
  printDryRun(`Would write: ~/${relativePath}`);
@@ -3874,7 +4296,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3874
4296
  const installedFiles = [];
3875
4297
  const fileHashes = {};
3876
4298
  for (const file of component.files) {
3877
- const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
4299
+ const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
3878
4300
  fileHashes[file] = await copyFile(srcPath, storeRoot, file, { force: options.force });
3879
4301
  installedFiles.push(file);
3880
4302
  fileCount++;
@@ -3920,7 +4342,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3920
4342
  }
3921
4343
 
3922
4344
  // src/commands/add.ts
3923
- import { join as join7 } from "path";
4345
+ import { join as join8 } from "path";
3924
4346
  async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3925
4347
  let manifest;
3926
4348
  try {
@@ -3956,7 +4378,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3956
4378
  const installedFiles = [];
3957
4379
  const fileHashes = {};
3958
4380
  for (const file of component.files) {
3959
- const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
4381
+ const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
3960
4382
  if (options.dryRun) {
3961
4383
  console.log(` [dry-run] Would copy: ${file}`);
3962
4384
  } else {
@@ -3966,7 +4388,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3966
4388
  fileCount++;
3967
4389
  }
3968
4390
  for (const dir of component.directories ?? []) {
3969
- const srcDirPath = join7(assetsDir, dir);
4391
+ const srcDirPath = join8(assetsDir, dir);
3970
4392
  if (options.dryRun) {
3971
4393
  console.log(` [dry-run] Would copy directory: ${dir}/`);
3972
4394
  } else {
@@ -4003,194 +4425,6 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
4003
4425
  import { dirname as dirname5, join as join9 } from "path";
4004
4426
  import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
4005
4427
 
4006
- // src/modules/hub.ts
4007
- import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
4008
- import { readdir as readdir6, writeFile as writeFile8 } from "fs/promises";
4009
- import { join as join8 } from "path";
4010
- var MANIFEST_RETROFITS = [
4011
- {
4012
- field: "role",
4013
- needsRetrofit: (m) => m.role === void 0,
4014
- ask: async () => {
4015
- const isHub = await confirm2({
4016
- message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
4017
- default: false
4018
- });
4019
- return { role: isHub ? "hub" : "consumer" };
4020
- }
4021
- },
4022
- {
4023
- field: "tracking_mode",
4024
- needsRetrofit: (m) => m.tracking_mode === void 0,
4025
- // Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
4026
- // profiles get a silent default, full/lite get asked.
4027
- ask: async (manifest) => {
4028
- if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
4029
- return { tracking_mode: "internal", external_provider: null };
4030
- }
4031
- const tracking_mode = await select3({
4032
- message: "How will work be tracked in this repo?",
4033
- choices: [
4034
- { value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
4035
- { value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
4036
- ]
4037
- });
4038
- if (tracking_mode === "external") {
4039
- const external_provider = await select3({
4040
- message: "Which external tracker?",
4041
- choices: [
4042
- { value: "ado", name: "Azure DevOps" },
4043
- { value: "github", name: "GitHub Issues" },
4044
- { value: "jira", name: "Jira" },
4045
- { value: "other", name: "Other" }
4046
- ]
4047
- });
4048
- return { tracking_mode, external_provider };
4049
- }
4050
- return { tracking_mode, external_provider: null };
4051
- }
4052
- },
4053
- {
4054
- field: "content_sensitivity",
4055
- needsRetrofit: (m) => m.content_sensitivity === void 0,
4056
- // EF-12. Asked for every profile -- a code repo can hold sensitive records
4057
- // too -- and defaults to "standard", so an operator who just presses enter
4058
- // keeps today's behaviour exactly.
4059
- ask: async () => {
4060
- const content_sensitivity = await select3({
4061
- message: "How should agents treat this repository's contents?",
4062
- choices: [
4063
- {
4064
- value: "standard",
4065
- name: "Standard \u2014 agents may quote contents in journals and decisions"
4066
- },
4067
- {
4068
- value: "sensitive",
4069
- name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
4070
- }
4071
- ],
4072
- default: "standard"
4073
- });
4074
- return { content_sensitivity };
4075
- }
4076
- },
4077
- {
4078
- field: "subject_root",
4079
- // EF-07. Only the docs profile is asked: other profiles describe code or a
4080
- // venture portfolio, where "what is this repo about" is already answered.
4081
- // A docs install that legitimately holds several subjects answers
4082
- // "portfolio" and stays unset -- so this retrofit deliberately does NOT
4083
- // re-ask on every update. It is gated on the field being absent AND the
4084
- // profile being docs, and once answered (either way) it never fires again,
4085
- // because "portfolio" writes an explicit empty marker.
4086
- needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
4087
- ask: async () => {
4088
- const shape = await select3({
4089
- message: "What does this repository hold?",
4090
- choices: [
4091
- {
4092
- value: "root",
4093
- name: "One subject, at the repository root (documents sit alongside Arcane's files)"
4094
- },
4095
- { value: "subdir", name: "One subject, in its own directory" },
4096
- { value: "portfolio", name: "Several subjects or ventures" }
4097
- ]
4098
- });
4099
- if (shape === "root") return { subject_root: "." };
4100
- if (shape === "subdir") {
4101
- const answer = await input3({
4102
- message: "Directory holding the subject's documents:",
4103
- default: "docs",
4104
- validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
4105
- });
4106
- return { subject_root: answer.trim() || "docs" };
4107
- }
4108
- return { subject_root: null };
4109
- }
4110
- },
4111
- {
4112
- field: "push_policy",
4113
- needsRetrofit: (m) => m.push_policy === void 0,
4114
- // EF-09. Defaults to "open", so an operator who just presses enter keeps
4115
- // today's behaviour exactly. NOTE: this only records the choice -- unlike
4116
- // init, the retrofit does not install the hook or disable the push URL,
4117
- // because an update should not silently start blocking pushes in a repo
4118
- // someone is mid-workflow in. doctor reports the gap instead.
4119
- ask: async () => {
4120
- const push_policy = await select3({
4121
- message: "Should this repository be allowed to push to a remote?",
4122
- choices: [
4123
- { value: "open", name: "Yes \u2014 normal repository" },
4124
- { value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
4125
- { value: "blocked", name: "No \u2014 block pushes" }
4126
- ],
4127
- default: "open"
4128
- });
4129
- return { push_policy };
4130
- }
4131
- }
4132
- ];
4133
- async function runManifestRetrofits(manifest) {
4134
- const applicable = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
4135
- if (applicable.length === 0) return {};
4136
- console.log();
4137
- console.log(
4138
- `This install predates ${applicable.length} manifest field${applicable.length === 1 ? "" : "s"} -- a couple of quick questions:`
4139
- );
4140
- let patch = {};
4141
- for (const retrofit of applicable) {
4142
- const answer = await retrofit.ask({ ...manifest, ...patch });
4143
- patch = { ...patch, ...answer };
4144
- }
4145
- return patch;
4146
- }
4147
- async function offerRegistryScaffold(targetDir, businessRoot) {
4148
- const registryPath = join8(targetDir, businessRoot, "registry.json");
4149
- if (await fileExists(registryPath)) return;
4150
- const businessRootPath = join8(targetDir, businessRoot);
4151
- let ventureDirs;
4152
- try {
4153
- const entries = await readdir6(businessRootPath, { withFileTypes: true });
4154
- ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
4155
- } catch {
4156
- return;
4157
- }
4158
- if (ventureDirs.length === 0) return;
4159
- console.log();
4160
- const scaffold = await confirm2({
4161
- message: `Found ${ventureDirs.length} folder${ventureDirs.length === 1 ? "" : "s"} under ${businessRoot}/ -- scaffold ${businessRoot}/registry.json from them?`,
4162
- default: true
4163
- });
4164
- if (!scaffold) return;
4165
- const ventures = {};
4166
- for (const slug of ventureDirs) {
4167
- const include = await confirm2({ message: ` Include "${slug}"?`, default: true });
4168
- if (!include) continue;
4169
- ventures[slug] = {
4170
- name: slug,
4171
- aliases: [],
4172
- status: "active",
4173
- visibility: "private",
4174
- ownership: "llc",
4175
- tracking: "none",
4176
- repos: []
4177
- };
4178
- }
4179
- if (Object.keys(ventures).length === 0) {
4180
- console.log(" No ventures selected -- registry not created.");
4181
- return;
4182
- }
4183
- const registry = {
4184
- _comment: "Hub-owned venture registry. Private. Never installed or modified by spell update. Seeded by the retrofit wizard -- review and enrich (aliases, ownership, tracking, clones) before relying on it.",
4185
- updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
4186
- ventures
4187
- };
4188
- await writeFile8(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
4189
- console.log(
4190
- ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
4191
- );
4192
- }
4193
-
4194
4428
  // src/modules/merge3.ts
4195
4429
  var CONFLICT_START = "<<<<<<< yours (edited)";
4196
4430
  var CONFLICT_MID = "=======";
@@ -4354,6 +4588,14 @@ async function missingTierWarning(scope, spellScope, homeDir) {
4354
4588
  ];
4355
4589
  }
4356
4590
  }
4591
+ function reportFlagPatch(flagPatch, before, after, dryRun) {
4592
+ console.log(`
4593
+ ${dryRun ? "[dry-run] Would record" : "Recorded"} from flags:`);
4594
+ for (const line of describeFlagPatch(flagPatch)) console.log(` ${line}`);
4595
+ if (!dryRun && after.push_policy !== before.push_policy) {
4596
+ for (const line of pushPolicyNotice(after.push_policy)) console.log(line);
4597
+ }
4598
+ }
4357
4599
  function defaultScopePatch(options, manifest) {
4358
4600
  if (options.defaultScope === void 0) return void 0;
4359
4601
  if (manifest.default_spell_scope === options.defaultScope) return void 0;
@@ -4496,6 +4738,27 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4496
4738
  return;
4497
4739
  }
4498
4740
  const scope = resolvedScope.scope;
4741
+ let flagPatch = {};
4742
+ if (hasManifestFlags(options.manifestFlags)) {
4743
+ if (scope !== "repo") {
4744
+ console.error(
4745
+ "\n \u2717 The manifest-question flags describe a repository; the user tier has none of those fields.\n"
4746
+ );
4747
+ process.exit(1);
4748
+ return;
4749
+ }
4750
+ try {
4751
+ flagPatch = resolveManifestFlags(options.manifestFlags, manifest, join9(targetDir, ".arcane.json"));
4752
+ } catch (err) {
4753
+ if (!(err instanceof ManifestFlagError)) throw err;
4754
+ console.error(`
4755
+ \u2717 ${err.message}
4756
+ `);
4757
+ process.exit(1);
4758
+ return;
4759
+ }
4760
+ }
4761
+ const answered = { ...manifest, ...flagPatch };
4499
4762
  const spellScope = effectiveSpellScope(manifest);
4500
4763
  const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
4501
4764
  const homeDir = dirname5(targetDir);
@@ -4536,6 +4799,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4536
4799
  const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
4537
4800
  if (missing.length === 0 && unmanaged.length === 0) {
4538
4801
  console.log("Already up to date.");
4802
+ if (scope === "repo" && Object.keys(flagPatch).length > 0) {
4803
+ if (!options.dryRun) await writeManifest(targetDir, answered);
4804
+ reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
4805
+ }
4539
4806
  if (scope === "user") {
4540
4807
  const fanout = await syncUserTierFanout({
4541
4808
  ownedClients: SPELL_FANOUT_CLIENTS,
@@ -4568,6 +4835,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4568
4835
  }
4569
4836
  if (manifest.components.length === 0) {
4570
4837
  console.log("No components installed. Nothing to update.");
4838
+ if (scope === "repo" && Object.keys(flagPatch).length > 0) {
4839
+ if (!options.dryRun) await writeManifest(targetDir, answered);
4840
+ reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
4841
+ }
4571
4842
  return;
4572
4843
  }
4573
4844
  let fileCount = 0;
@@ -4789,7 +5060,8 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4789
5060
  `
4790
5061
  [dry-run] Would update ${fileCount} files.`
4791
5062
  );
4792
- const applicableRetrofits = scope === "repo" ? MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest)) : [];
5063
+ if (Object.keys(flagPatch).length > 0) reportFlagPatch(flagPatch, manifest, answered, true);
5064
+ const applicableRetrofits = scope === "repo" ? MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(answered)) : [];
4793
5065
  if (applicableRetrofits.length > 0) {
4794
5066
  console.log(
4795
5067
  `[dry-run] Would ask ${applicableRetrofits.length} manifest retrofit question${applicableRetrofits.length === 1 ? "" : "s"}: ${applicableRetrofits.map((r) => r.field).join(", ")}.`
@@ -4798,19 +5070,21 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4798
5070
  return;
4799
5071
  }
4800
5072
  const interactive = Boolean(process.stdin.isTTY) && scope === "repo";
4801
- const retrofitPatch = interactive ? await runManifestRetrofits(manifest) : {};
5073
+ const retrofitPatch = interactive ? await runManifestRetrofits(answered, targetDir) : {};
4802
5074
  if (!interactive && scope === "repo") {
4803
- const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
5075
+ const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(answered));
4804
5076
  if (pending.length > 0) {
4805
5077
  console.log(
4806
- ` ! 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.`
5078
+ ` ! 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, or answer by flags:`
4807
5079
  );
5080
+ for (const line of manifestFlagLine(answered) ?? []) console.log(` ${line}`);
4808
5081
  }
4809
5082
  }
4810
5083
  const updated = {
4811
5084
  ...manifest,
4812
5085
  version: packageVersion,
4813
5086
  components: updatedComponents,
5087
+ ...flagPatch,
4814
5088
  ...retrofitPatch,
4815
5089
  ...fanoutRecord !== void 0 ? { fanout: fanoutRecord } : {},
4816
5090
  ...defaultScopePatch(options, manifest)
@@ -4820,6 +5094,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4820
5094
  `
4821
5095
  \u2713 Updated ${fileCount} files.`
4822
5096
  );
5097
+ if (Object.keys(flagPatch).length > 0) {
5098
+ reportFlagPatch(flagPatch, manifest, updated, false);
5099
+ } else if (updated.push_policy !== manifest.push_policy) {
5100
+ for (const line of pushPolicyNotice(updated.push_policy)) console.log(line);
5101
+ }
4823
5102
  if (conflictedFiles.length > 0) {
4824
5103
  console.log(
4825
5104
  `
@@ -4829,7 +5108,13 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4829
5108
  );
4830
5109
  }
4831
5110
  if (scope === "repo" && manifest.role !== "hub" && updated.role === "hub") {
4832
- await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
5111
+ if (process.stdin.isTTY) {
5112
+ await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
5113
+ } else {
5114
+ console.log(
5115
+ ` This repository is now a hub. Run \`spell update\` from a terminal to scaffold ${updated.business_root ?? "ventures"}/registry.json from existing venture folders.`
5116
+ );
5117
+ }
4833
5118
  }
4834
5119
  }
4835
5120
 
@@ -5240,8 +5525,8 @@ async function uninstallUserTier(options, storeRoot, manifest) {
5240
5525
  }
5241
5526
 
5242
5527
  // src/commands/doctor.ts
5243
- import { access as access4, readdir as readdir8, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
5244
- import { join as join13, dirname as dirname9 } from "path";
5528
+ import { access as access4, readdir as readdir9, readFile as readFile11, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
5529
+ import { join as join14, dirname as dirname9 } from "path";
5245
5530
 
5246
5531
  // src/config/incidents.ts
5247
5532
  var INCIDENT_QUEUE = [
@@ -5576,6 +5861,133 @@ function parseAdoRemote(url) {
5576
5861
  return null;
5577
5862
  }
5578
5863
 
5864
+ // src/modules/placeholders.ts
5865
+ import { readdir as readdir8, readFile as readFile10 } from "fs/promises";
5866
+ import { join as join13 } from "path";
5867
+ var PLACEHOLDER_STANDARD_FILE = "spell-authoring-standards.md";
5868
+ var RUNTIME_PLACEHOLDERS_START = "<!-- runtime-placeholders:start -->";
5869
+ var RUNTIME_PLACEHOLDERS_END = "<!-- runtime-placeholders:end -->";
5870
+ var PLACEHOLDER_PATTERN = /(?<!\$)\{([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)\}/g;
5871
+ var FRONTMATTER_PATTERN2 = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
5872
+ function parseFrontmatterStatus(content) {
5873
+ const block = FRONTMATTER_PATTERN2.exec(content);
5874
+ if (!block) return null;
5875
+ const status = /^status:[ \t]*["']?([^"'\s#]+)/m.exec(block[1]);
5876
+ return status ? status[1] : null;
5877
+ }
5878
+ function stripFrontmatter(content) {
5879
+ const block = FRONTMATTER_PATTERN2.exec(content);
5880
+ return block ? content.slice(block[0].length) : content;
5881
+ }
5882
+ function stripFencedCode(text) {
5883
+ const kept = [];
5884
+ let fence = null;
5885
+ for (const line of text.split(/\r?\n/)) {
5886
+ if (fence) {
5887
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
5888
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = null;
5889
+ kept.push("");
5890
+ continue;
5891
+ }
5892
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
5893
+ if (open && !(open[1][0] === "`" && line.slice(open[0].length).includes("`"))) {
5894
+ fence = { char: open[1][0], length: open[1].length };
5895
+ kept.push("");
5896
+ continue;
5897
+ }
5898
+ kept.push(line);
5899
+ }
5900
+ return kept.join("\n");
5901
+ }
5902
+ function stripInlineCode(text) {
5903
+ return text.split(/\n[ \t]*\n/).map((paragraph) => {
5904
+ let out = "";
5905
+ let i = 0;
5906
+ while (i < paragraph.length) {
5907
+ if (paragraph[i] !== "`") {
5908
+ out += paragraph[i];
5909
+ i += 1;
5910
+ continue;
5911
+ }
5912
+ let runEnd = i;
5913
+ while (paragraph[runEnd] === "`") runEnd += 1;
5914
+ const run = runEnd - i;
5915
+ let close = -1;
5916
+ let j = runEnd;
5917
+ while (j < paragraph.length) {
5918
+ if (paragraph[j] !== "`") {
5919
+ j += 1;
5920
+ continue;
5921
+ }
5922
+ let k = j;
5923
+ while (paragraph[k] === "`") k += 1;
5924
+ if (k - j === run) {
5925
+ close = j;
5926
+ break;
5927
+ }
5928
+ j = k;
5929
+ }
5930
+ if (close === -1) {
5931
+ out += paragraph.slice(i, runEnd);
5932
+ i = runEnd;
5933
+ } else {
5934
+ out += " ";
5935
+ i = close + run;
5936
+ }
5937
+ }
5938
+ return out;
5939
+ }).join("\n\n");
5940
+ }
5941
+ function findPlaceholderTokens(content) {
5942
+ const prose = stripInlineCode(stripFencedCode(stripFrontmatter(content)));
5943
+ const seen = /* @__PURE__ */ new Set();
5944
+ for (const match of prose.matchAll(PLACEHOLDER_PATTERN)) seen.add(`{${match[1]}}`);
5945
+ return [...seen];
5946
+ }
5947
+ function parseRuntimePlaceholders(standardContent) {
5948
+ const start = standardContent.indexOf(RUNTIME_PLACEHOLDERS_START);
5949
+ const end = standardContent.indexOf(RUNTIME_PLACEHOLDERS_END);
5950
+ if (start === -1 || end === -1 || end < start) return null;
5951
+ const region = standardContent.slice(start + RUNTIME_PLACEHOLDERS_START.length, end);
5952
+ const tokens = /* @__PURE__ */ new Set();
5953
+ for (const match of region.matchAll(/^[ \t]*[-*][ \t]+`(\{[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*\})`/gm)) {
5954
+ tokens.add(match[1]);
5955
+ }
5956
+ return tokens;
5957
+ }
5958
+ async function scanGovernancePlaceholders(governanceDir) {
5959
+ let standard;
5960
+ try {
5961
+ standard = await readFile10(join13(governanceDir, PLACEHOLDER_STANDARD_FILE), "utf8");
5962
+ } catch (err) {
5963
+ if (err.code === "ENOENT") {
5964
+ return { status: "skipped", reason: `${PLACEHOLDER_STANDARD_FILE} is not installed` };
5965
+ }
5966
+ throw err;
5967
+ }
5968
+ const runtimeTokens = parseRuntimePlaceholders(standard);
5969
+ if (!runtimeTokens) {
5970
+ return {
5971
+ status: "skipped",
5972
+ reason: `${PLACEHOLDER_STANDARD_FILE} has no runtime-placeholders list (an older version?)`
5973
+ };
5974
+ }
5975
+ const files = (await readdir8(governanceDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name).sort();
5976
+ const activeDocs = [];
5977
+ const templateDocs = [];
5978
+ const findings = [];
5979
+ for (const file of files) {
5980
+ const content = await readFile10(join13(governanceDir, file), "utf8");
5981
+ const status = parseFrontmatterStatus(content);
5982
+ if (status === "template") templateDocs.push(file);
5983
+ if (status !== "active") continue;
5984
+ activeDocs.push(file);
5985
+ const unknown = findPlaceholderTokens(content).filter((token) => !runtimeTokens.has(token));
5986
+ if (unknown.length > 0) findings.push({ file, tokens: unknown });
5987
+ }
5988
+ return { status: "scanned", runtimeTokens, activeDocs, templateDocs, findings };
5989
+ }
5990
+
5579
5991
  // src/commands/doctor.ts
5580
5992
  function isRecord3(value) {
5581
5993
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -5596,14 +6008,14 @@ async function checkNodeVersion() {
5596
6008
  async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
5597
6009
  const name = `VS Code extension: ${label}`;
5598
6010
  const flavours = [
5599
- { dir: join13(homeDir, ".vscode", "extensions"), bin: "code" },
5600
- { dir: join13(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
6011
+ { dir: join14(homeDir, ".vscode", "extensions"), bin: "code" },
6012
+ { dir: join14(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
5601
6013
  ];
5602
6014
  let installBin = null;
5603
6015
  for (const flavour of flavours) {
5604
6016
  let entries;
5605
6017
  try {
5606
- entries = await readdir8(flavour.dir);
6018
+ entries = await readdir9(flavour.dir);
5607
6019
  } catch {
5608
6020
  continue;
5609
6021
  }
@@ -5625,13 +6037,13 @@ async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
5625
6037
  }
5626
6038
  async function checkArcaneManifest(targetDir) {
5627
6039
  const name = "Arcane manifest (.arcane.json)";
5628
- const manifestPath2 = join13(targetDir, ".arcane.json");
6040
+ const manifestPath2 = join14(targetDir, ".arcane.json");
5629
6041
  try {
5630
6042
  await access4(manifestPath2);
5631
6043
  } catch {
5632
- const selfHostedManifestPath = join13(targetDir, "src", "assets", ".arcane.json");
6044
+ const selfHostedManifestPath = join14(targetDir, "src", "assets", ".arcane.json");
5633
6045
  try {
5634
- const raw = await readFile10(selfHostedManifestPath, "utf8");
6046
+ const raw = await readFile11(selfHostedManifestPath, "utf8");
5635
6047
  const manifest = JSON.parse(raw);
5636
6048
  if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
5637
6049
  return {
@@ -5650,7 +6062,7 @@ async function checkArcaneManifest(targetDir) {
5650
6062
  };
5651
6063
  }
5652
6064
  try {
5653
- const raw = await readFile10(manifestPath2, "utf8");
6065
+ const raw = await readFile11(manifestPath2, "utf8");
5654
6066
  const manifest = JSON.parse(raw);
5655
6067
  if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
5656
6068
  return {
@@ -5735,7 +6147,7 @@ async function checkSessionContinuity(targetDir) {
5735
6147
  const results = [];
5736
6148
  for (const file of SESSION_CONTINUITY_FILES) {
5737
6149
  const name = `Session file: ${file}`;
5738
- const filePath = join13(targetDir, file);
6150
+ const filePath = join14(targetDir, file);
5739
6151
  try {
5740
6152
  await access4(filePath);
5741
6153
  results.push({ name, passed: true, message: "present" });
@@ -5753,11 +6165,11 @@ async function checkSessionContinuity(targetDir) {
5753
6165
  async function fixSessionContinuity(targetDir, assetsDir) {
5754
6166
  const created = [];
5755
6167
  for (const file of SESSION_CONTINUITY_FILES) {
5756
- const destPath = join13(targetDir, file);
6168
+ const destPath = join14(targetDir, file);
5757
6169
  try {
5758
6170
  await access4(destPath);
5759
6171
  } catch {
5760
- const srcPath = join13(assetsDir, file);
6172
+ const srcPath = join14(assetsDir, file);
5761
6173
  await mkdir7(dirname9(destPath), { recursive: true });
5762
6174
  await fsCopyFile(srcPath, destPath);
5763
6175
  created.push(file);
@@ -5877,15 +6289,48 @@ async function checkPushPolicy(targetDir) {
5877
6289
  name,
5878
6290
  passed: false,
5879
6291
  blocking: false,
5880
- message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have.`
6292
+ // ARC-049 decision 3: name the command that closes the gap.
6293
+ message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have. Run \`spell block-push\` to install the controls.`
6294
+ };
6295
+ }
6296
+ async function checkGovernancePlaceholders(targetDir) {
6297
+ const name = "Governance placeholders (ARC-051)";
6298
+ let scan;
6299
+ try {
6300
+ scan = await scanGovernancePlaceholders(join14(targetDir, ".arcane", "governance"));
6301
+ } catch (err) {
6302
+ return {
6303
+ name,
6304
+ passed: false,
6305
+ blocking: false,
6306
+ message: `could not scan .arcane/governance (${err instanceof Error ? err.message : String(err)})`
6307
+ };
6308
+ }
6309
+ if (scan.status === "skipped") {
6310
+ return { name, passed: true, blocking: false, skipped: true, message: `skipped \u2014 ${scan.reason}` };
6311
+ }
6312
+ if (scan.findings.length === 0) {
6313
+ return {
6314
+ name,
6315
+ passed: true,
6316
+ blocking: false,
6317
+ message: `${scan.activeDocs.length} active doc(s) scanned, no unknown placeholders`
6318
+ };
6319
+ }
6320
+ const listed = scan.findings.map(({ file, tokens }) => `.arcane/governance/${file}: ${tokens.join(", ")}`);
6321
+ return {
6322
+ name,
6323
+ passed: false,
6324
+ blocking: false,
6325
+ message: `unknown placeholder(s) in status: active doc(s) \u2014 ${listed.join("; ")}. Fill each one in; or, if an agent resolves it at use time, add it to the runtime-placeholders list in .arcane/governance/${PLACEHOLDER_STANDARD_FILE}; or, if the document is meant to be filled in by hand, mark it \`status: template\`.`
5881
6326
  };
5882
6327
  }
5883
6328
  async function checkMcpConfig(targetDir) {
5884
6329
  const name = "MCP config (per-server timeout, I12)";
5885
- const filePath = join13(targetDir, ".mcp.json");
6330
+ const filePath = join14(targetDir, ".mcp.json");
5886
6331
  let raw;
5887
6332
  try {
5888
- raw = await readFile10(filePath, "utf8");
6333
+ raw = await readFile11(filePath, "utf8");
5889
6334
  } catch {
5890
6335
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
5891
6336
  }
@@ -5938,10 +6383,10 @@ function isDelegationsFile(value) {
5938
6383
  }
5939
6384
  async function checkDelegations(targetDir) {
5940
6385
  const name = "Standing delegations (T13)";
5941
- const filePath = join13(targetDir, ".arcane", "delegations.json");
6386
+ const filePath = join14(targetDir, ".arcane", "delegations.json");
5942
6387
  let raw;
5943
6388
  try {
5944
- raw = await readFile10(filePath, "utf8");
6389
+ raw = await readFile11(filePath, "utf8");
5945
6390
  } catch {
5946
6391
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
5947
6392
  }
@@ -6051,7 +6496,7 @@ async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
6051
6496
  let probed = false;
6052
6497
  const probe = "spell-status.md";
6053
6498
  try {
6054
- const entries = await readdir8(join13(storeRoot, USER_TIER_SPELLS_DIR));
6499
+ const entries = await readdir9(join14(storeRoot, USER_TIER_SPELLS_DIR));
6055
6500
  const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
6056
6501
  spellCount = spells.length;
6057
6502
  probed = spells.includes(probe);
@@ -6114,16 +6559,18 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6114
6559
  checkDelegations(targetDir),
6115
6560
  checkMcpConfig(targetDir),
6116
6561
  checkUserTier(resolveHomeDir(), options.packageVersion),
6117
- checkSpellScope(targetDir)
6562
+ checkSpellScope(targetDir),
6563
+ checkGovernancePlaceholders(targetDir)
6118
6564
  ]);
6119
6565
  const sessionResults = await checkSessionContinuity(targetDir);
6120
6566
  results.push(...sessionResults);
6121
6567
  let allPassed = true;
6122
6568
  for (const result of results) {
6123
- const icon = result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
6124
- const label = result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
6569
+ const skipped = result.passed && result.skipped === true;
6570
+ const icon = skipped ? "\u2013" : result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
6571
+ const label = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
6125
6572
  console.log(` ${icon} [${label}] ${result.name}`);
6126
- if (!result.passed) {
6573
+ if (!result.passed || skipped) {
6127
6574
  console.log(` ${result.message}`);
6128
6575
  }
6129
6576
  if (!result.passed && result.blocking !== false) {
@@ -6157,16 +6604,16 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6157
6604
  }
6158
6605
 
6159
6606
  // src/commands/ward.ts
6160
- import { readFile as readFile12 } from "fs/promises";
6607
+ import { readFile as readFile13 } from "fs/promises";
6161
6608
 
6162
6609
  // src/modules/ward.ts
6163
- import { readdir as readdir9, readFile as readFile11 } from "fs/promises";
6164
- import { join as join14, relative as relative2 } from "path";
6610
+ import { readdir as readdir10, readFile as readFile12 } from "fs/promises";
6611
+ import { join as join15, relative as relative2 } from "path";
6165
6612
  async function scanFileAllRules(absolutePath, displayPath, rules) {
6166
6613
  const findings = [];
6167
6614
  let content;
6168
6615
  try {
6169
- content = await readFile11(absolutePath, "utf8");
6616
+ content = await readFile12(absolutePath, "utf8");
6170
6617
  } catch {
6171
6618
  return findings;
6172
6619
  }
@@ -6219,12 +6666,12 @@ async function scanFilenames(root, rules, repoRoot = root) {
6219
6666
  const findings = [];
6220
6667
  let entries;
6221
6668
  try {
6222
- entries = await readdir9(root, { withFileTypes: true });
6669
+ entries = await readdir10(root, { withFileTypes: true });
6223
6670
  } catch {
6224
6671
  return findings;
6225
6672
  }
6226
6673
  for (const entry of entries) {
6227
- const absolutePath = join14(root, entry.name);
6674
+ const absolutePath = join15(root, entry.name);
6228
6675
  if (entry.isDirectory()) {
6229
6676
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
6230
6677
  findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
@@ -6261,7 +6708,7 @@ async function runWard(repoRoot, options = {}) {
6261
6708
  const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
6262
6709
  if (findings2.length > 0) {
6263
6710
  try {
6264
- fileLines.set(displayPath, (await readFile11(absolutePath, "utf8")).split("\n"));
6711
+ fileLines.set(displayPath, (await readFile12(absolutePath, "utf8")).split("\n"));
6265
6712
  } catch {
6266
6713
  }
6267
6714
  }
@@ -6289,7 +6736,7 @@ function parseTermsFileContent(raw) {
6289
6736
  }
6290
6737
  async function readTermsFile(filePath) {
6291
6738
  try {
6292
- return parseTermsFileContent(await readFile12(filePath, "utf8"));
6739
+ return parseTermsFileContent(await readFile13(filePath, "utf8"));
6293
6740
  } catch (error) {
6294
6741
  console.warn(
6295
6742
  `\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
@@ -6436,17 +6883,80 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
6436
6883
  );
6437
6884
  }
6438
6885
 
6886
+ // src/commands/block-push.ts
6887
+ async function runBlockPush(targetDir) {
6888
+ let manifest;
6889
+ try {
6890
+ manifest = await readManifest(targetDir);
6891
+ } catch (err) {
6892
+ if (err instanceof ManifestNotFoundError) {
6893
+ console.error('Not initialized. Run "spell init" first.');
6894
+ process.exit(1);
6895
+ return;
6896
+ }
6897
+ throw err;
6898
+ }
6899
+ if (manifest.scope === "user") {
6900
+ console.error(
6901
+ "spell block-push applies to a repository, not the user tier: push_policy describes a repository."
6902
+ );
6903
+ process.exit(1);
6904
+ return;
6905
+ }
6906
+ if ((await inspectGitRepository(targetDir)).status === "not-repository") {
6907
+ console.error("spell block-push needs a Git repository: there is nothing here to block.");
6908
+ process.exit(1);
6909
+ return;
6910
+ }
6911
+ const recorded = manifest.push_policy ?? "open";
6912
+ const alreadyEnforced = recorded === "blocked" && await isHookEnforced(targetDir) && (await undisabledRemotes(targetDir)).length === 0;
6913
+ if (alreadyEnforced) {
6914
+ printSuccess("Push is already blocked and enforced here. Nothing changed.");
6915
+ if ((await listRemotes(targetDir)).length === 0) {
6916
+ printWarning(
6917
+ "No remote is configured, so only the pre-push hook is active. Re-run `spell block-push` after adding a remote to disable its push URL too."
6918
+ );
6919
+ }
6920
+ printInfo("Run `spell unblock-push` from a terminal to undo this.");
6921
+ return;
6922
+ }
6923
+ const outcome = await applyBlockedPushControls(targetDir, {
6924
+ stopOnHookRefusal: true,
6925
+ emit: (m) => m.level === "warning" ? printWarning(m.text) : printInfo(m.text)
6926
+ });
6927
+ const failedRemotes = outcome.urls.filter((u) => u.status === "failed");
6928
+ if (!hookInForce(outcome.hook) || failedRemotes.length > 0) {
6929
+ printWarning(
6930
+ outcome.stoppedAtHook ? "Nothing was changed. Resolve the refusal above and re-run `spell block-push`." : "Push is NOT fully blocked: the remotes named above can still be pushed to. Fix them and re-run `spell block-push`."
6931
+ );
6932
+ if (recorded !== "blocked") {
6933
+ printInfo(
6934
+ `The manifest still records push_policy: "${recorded}" \u2014 it is not changed to "blocked" until both controls are in force.`
6935
+ );
6936
+ }
6937
+ process.exit(1);
6938
+ return;
6939
+ }
6940
+ if (recorded !== "blocked") {
6941
+ await writeManifest(targetDir, { ...manifest, push_policy: "blocked" });
6942
+ printSuccess(`Push blocked. Recorded push_policy: "blocked" (was "${recorded}") in .arcane.json.`);
6943
+ printInfo("Commit that change so the repository's posture stays visible to everyone.");
6944
+ } else {
6945
+ printSuccess('Push blocked. The manifest already recorded push_policy: "blocked"; it is now enforced.');
6946
+ }
6947
+ }
6948
+
6439
6949
  // src/commands/report.ts
6440
6950
  import { access as access5 } from "fs/promises";
6441
- import { join as join17 } from "path";
6951
+ import { join as join18 } from "path";
6442
6952
 
6443
6953
  // src/modules/show-report/generate.ts
6444
- import { mkdir as mkdir8, readdir as readdir10, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
6445
- import { basename as basename3, dirname as dirname10, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
6954
+ import { mkdir as mkdir8, readdir as readdir11, readFile as readFile15, writeFile as writeFile11 } from "fs/promises";
6955
+ import { basename as basename3, dirname as dirname10, isAbsolute, join as join17, relative as relative3, resolve as resolve4 } from "path";
6446
6956
 
6447
6957
  // src/modules/show-report/model.ts
6448
- import { readFile as readFile13 } from "fs/promises";
6449
- import { join as join15 } from "path";
6958
+ import { readFile as readFile14 } from "fs/promises";
6959
+ import { join as join16 } from "path";
6450
6960
 
6451
6961
  // src/modules/show-report/plan-parser.ts
6452
6962
  function parseFrontmatter(content) {
@@ -6716,7 +7226,7 @@ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
6716
7226
  // src/modules/show-report/model.ts
6717
7227
  async function readOptional(path4) {
6718
7228
  try {
6719
- return await readFile13(path4, "utf8");
7229
+ return await readFile14(path4, "utf8");
6720
7230
  } catch {
6721
7231
  return null;
6722
7232
  }
@@ -6812,13 +7322,13 @@ function buildCorrections(ledgerRows, programName) {
6812
7322
  };
6813
7323
  }
6814
7324
  async function buildShowReportModel(options) {
6815
- const planAbsPath = join15(options.rootDir, options.planRelPath);
6816
- const queueAbsPath = join15(options.rootDir, options.queueRelPath);
7325
+ const planAbsPath = join16(options.rootDir, options.planRelPath);
7326
+ const queueAbsPath = join16(options.rootDir, options.queueRelPath);
6817
7327
  const ledgerRelPath = "docs/verification-ledger.md";
6818
- const ledgerAbsPath = join15(options.rootDir, ledgerRelPath);
6819
- const decisionsAbsPath = join15(options.rootDir, "DECISIONS.md");
7328
+ const ledgerAbsPath = join16(options.rootDir, ledgerRelPath);
7329
+ const decisionsAbsPath = join16(options.rootDir, "DECISIONS.md");
6820
7330
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
6821
- readFile13(planAbsPath, "utf8"),
7331
+ readFile14(planAbsPath, "utf8"),
6822
7332
  readOptional(queueAbsPath),
6823
7333
  readOptional(ledgerAbsPath),
6824
7334
  readOptional(decisionsAbsPath)
@@ -6987,7 +7497,7 @@ function toPosix(path4) {
6987
7497
  async function programFromPlan(rootDir, slug, planRelPath) {
6988
7498
  let planContent;
6989
7499
  try {
6990
- planContent = await readFile14(join16(rootDir, planRelPath), "utf8");
7500
+ planContent = await readFile15(join17(rootDir, planRelPath), "utf8");
6991
7501
  } catch {
6992
7502
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
6993
7503
  }
@@ -6999,16 +7509,16 @@ async function programFromPlan(rootDir, slug, planRelPath) {
6999
7509
  program: {
7000
7510
  slug,
7001
7511
  planRelPath,
7002
- queueRelPath: toPosix(join16(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
7512
+ queueRelPath: toPosix(join17(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
7003
7513
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
7004
7514
  }
7005
7515
  };
7006
7516
  }
7007
7517
  async function discoverPrograms(rootDir) {
7008
- const plansDir = join16(rootDir, "docs", "plans");
7518
+ const plansDir = join17(rootDir, "docs", "plans");
7009
7519
  let entries;
7010
7520
  try {
7011
- entries = (await readdir10(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
7521
+ entries = (await readdir11(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
7012
7522
  } catch (error) {
7013
7523
  if (error.code === "ENOENT") return { programs: [], skipped: [] };
7014
7524
  throw error;
@@ -7032,14 +7542,14 @@ function normalizeLineEndings2(content) {
7032
7542
  }
7033
7543
  async function readOptionalFile(path4) {
7034
7544
  try {
7035
- return await readFile14(path4, "utf8");
7545
+ return await readFile15(path4, "utf8");
7036
7546
  } catch (error) {
7037
7547
  if (error.code === "ENOENT") return null;
7038
7548
  throw error;
7039
7549
  }
7040
7550
  }
7041
7551
  async function generateReport(rootDir, program2, templateHtml) {
7042
- const planContent = await readFile14(join16(rootDir, program2.planRelPath), "utf8");
7552
+ const planContent = await readFile15(join17(rootDir, program2.planRelPath), "utf8");
7043
7553
  const frontmatter = parseFrontmatter(planContent);
7044
7554
  const model = await buildShowReportModel({
7045
7555
  rootDir,
@@ -7057,7 +7567,7 @@ async function generateReport(rootDir, program2, templateHtml) {
7057
7567
  };
7058
7568
  }
7059
7569
  async function runReportGeneration(options) {
7060
- const templateHtml = await readFile14(options.templatePath, "utf8");
7570
+ const templateHtml = await readFile15(options.templatePath, "utf8");
7061
7571
  const drifted = [];
7062
7572
  const repaired = [];
7063
7573
  const unwritten = [];
@@ -7072,12 +7582,12 @@ async function runReportGeneration(options) {
7072
7582
  [`${outRel}/show-report.html`, generated.html]
7073
7583
  ];
7074
7584
  for (const [relPath, expected] of outputs) {
7075
- const actual = await readOptionalFile(join16(options.rootDir, relPath));
7585
+ const actual = await readOptionalFile(join17(options.rootDir, relPath));
7076
7586
  if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
7077
7587
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
7078
7588
  if (options.mode === "write") {
7079
7589
  await mkdir8(outAbs, { recursive: true });
7080
- await writeFile11(join16(options.rootDir, relPath), expected, "utf8");
7590
+ await writeFile11(join17(options.rootDir, relPath), expected, "utf8");
7081
7591
  repaired.push(relPath);
7082
7592
  }
7083
7593
  }
@@ -7088,7 +7598,7 @@ async function runReportGeneration(options) {
7088
7598
 
7089
7599
  // src/commands/report.ts
7090
7600
  async function runReport(targetDir, options = {}, assetsDir) {
7091
- const templatePath = join17(assetsDir, "report", "show-report.template.html");
7601
+ const templatePath = join18(assetsDir, "report", "show-report.template.html");
7092
7602
  try {
7093
7603
  await access5(templatePath);
7094
7604
  } catch {
@@ -7151,13 +7661,29 @@ async function runReport(targetDir, options = {}, assetsDir) {
7151
7661
  import { realpathSync } from "fs";
7152
7662
  var require3 = createRequire2(import.meta.url);
7153
7663
  var pkg = require3("../package.json");
7154
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join18(dirname11(fileURLToPath(import.meta.url)), "assets");
7664
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join19(dirname11(fileURLToPath(import.meta.url)), "assets");
7155
7665
  var program = new Command();
7156
7666
  var USER_FLAG_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 spells shared by every repository on this machine, fanned out to ~/.agents/skills (Codex, Copilot) and ~/.claude/commands (Claude Code)";
7157
7667
  var USER_TIER_AGENTS_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 one agent roster shared by every repository on this machine, rendered to ~/.copilot/agents (VS Code agent modes)";
7158
7668
  function targetDirFor(opts) {
7159
7669
  return opts.user ? userTierRoot() : process.cwd();
7160
7670
  }
7671
+ function withManifestFlags(command) {
7672
+ return command.option("--role <role>", "Answer the hub question: hub | consumer").option("--tracking-mode <mode>", "Answer the tracking question: internal | external (external needs --external-provider)").option("--external-provider <provider>", "With --tracking-mode external: ado | github | jira | other").option("--content-sensitivity <level>", "Answer the content question: standard | sensitive").option(
7673
+ "--push-policy <policy>",
7674
+ "Answer the push question: open | guarded | blocked. Can tighten a recorded policy, never loosen it (use `spell unblock-push`)"
7675
+ ).option("--subject-root <path>", "Answer the subject question with a directory (docs profile)");
7676
+ }
7677
+ function manifestFlagsFrom(opts) {
7678
+ return {
7679
+ role: opts.role,
7680
+ trackingMode: opts.trackingMode,
7681
+ externalProvider: opts.externalProvider,
7682
+ contentSensitivity: opts.contentSensitivity,
7683
+ pushPolicy: opts.pushPolicy,
7684
+ subjectRoot: opts.subjectRoot
7685
+ };
7686
+ }
7161
7687
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
7162
7688
  if (program.args.length > 0) {
7163
7689
  const [attempted] = program.args;
@@ -7177,14 +7703,17 @@ program.name("spell").description("Arcane framework CLI \u2014 scaffold and mana
7177
7703
  await printWelcome(pkg.version, process.cwd());
7178
7704
  program.outputHelp();
7179
7705
  });
7180
- program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | docs | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").option("--user", USER_FLAG_DESCRIPTION).action(
7706
+ withManifestFlags(
7707
+ program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | docs | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").option("--user", USER_FLAG_DESCRIPTION)
7708
+ ).action(
7181
7709
  async (opts) => {
7182
7710
  await runInit(
7183
7711
  {
7184
7712
  profile: opts.profile,
7185
7713
  force: opts.force,
7186
7714
  dryRun: opts.dryRun,
7187
- user: opts.user
7715
+ user: opts.user,
7716
+ manifestFlags: manifestFlagsFrom(opts)
7188
7717
  },
7189
7718
  targetDirFor(opts),
7190
7719
  ASSETS_DIR,
@@ -7203,12 +7732,14 @@ program.command("add <component>").description("Add a component to an existing A
7203
7732
  );
7204
7733
  }
7205
7734
  );
7206
- program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
7207
- "--prune",
7208
- "Also delete orphaned managed files (tracked before this update, no longer part of any current component). Hash-checked: an edited file is reported, never silently deleted"
7209
- ).option("--user", USER_FLAG_DESCRIPTION).option(
7210
- "--default-scope <scope>",
7211
- "With --user: remember repo | user as the answer `spell init` pre-selects for NEW repositories on this machine. Never rewrites an existing repository, and the question is still asked"
7735
+ withManifestFlags(
7736
+ program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
7737
+ "--prune",
7738
+ "Also delete orphaned managed files (tracked before this update, no longer part of any current component). Hash-checked: an edited file is reported, never silently deleted"
7739
+ ).option("--user", USER_FLAG_DESCRIPTION).option(
7740
+ "--default-scope <scope>",
7741
+ "With --user: remember repo | user as the answer `spell init` pre-selects for NEW repositories on this machine. Never rewrites an existing repository, and the question is still asked"
7742
+ )
7212
7743
  ).action(async (opts) => {
7213
7744
  if (opts.defaultScope !== void 0) {
7214
7745
  if (!opts.user) {
@@ -7229,6 +7760,7 @@ program.command("update").description("Update installed components to the curren
7229
7760
  dryRun: opts.dryRun,
7230
7761
  prune: opts.prune,
7231
7762
  user: opts.user,
7763
+ manifestFlags: manifestFlagsFrom(opts),
7232
7764
  ...opts.defaultScope === void 0 ? {} : { defaultScope: opts.defaultScope }
7233
7765
  },
7234
7766
  targetDirFor(opts),
@@ -7242,6 +7774,9 @@ program.command("status").description("Show installed components and check for u
7242
7774
  program.command("uninstall").description("Remove all installed Arcane framework files").option("--yes", "Skip confirmation prompt").option("--dry-run", "Preview what would be removed without deleting files").option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
7243
7775
  await runUninstall({ yes: opts.yes, dryRun: opts.dryRun, user: opts.user }, targetDirFor(opts));
7244
7776
  });
7777
+ program.command("block-push").description("Enforce push_policy: blocked on this repository (pre-push hook + disabled push URLs); only ever tightens").action(async () => {
7778
+ await runBlockPush(process.cwd());
7779
+ });
7245
7780
  program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
7246
7781
  await runUnblockPush(process.cwd());
7247
7782
  });