arcane-cli 1.5.0 → 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) {
@@ -262,7 +276,16 @@ async function resolveSecretsScanExcludePrefixes(targetDir) {
262
276
  }
263
277
  async function writeManifest(targetDir, manifest) {
264
278
  const filePath = manifestPath(targetDir);
265
- await writeFile2(filePath, JSON.stringify(manifest, null, 2), "utf-8");
279
+ let existing = "";
280
+ try {
281
+ existing = await readFile2(filePath, "utf-8");
282
+ } catch {
283
+ existing = "";
284
+ }
285
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
286
+ const trailingNewline = /\r?\n$/.test(existing) ? eol : "";
287
+ const body = JSON.stringify(manifest, null, 2).replaceAll("\n", eol);
288
+ await writeFile2(filePath, body + trailingNewline, "utf-8");
266
289
  }
267
290
  function addComponent(manifest, component) {
268
291
  return {
@@ -2637,10 +2660,10 @@ async function runAgentsList(targetDir, scope = "repo") {
2637
2660
  console.log();
2638
2661
  }
2639
2662
 
2640
- // src/modules/push-safety.ts
2641
- import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm4, readdir as readdir5 } from "fs/promises";
2642
- import { constants } from "fs";
2643
- 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";
2644
2667
 
2645
2668
  // src/modules/git.ts
2646
2669
  import { execFile } from "child_process";
@@ -2860,7 +2883,371 @@ async function ensureLocalPullRebase(cwd) {
2860
2883
  }
2861
2884
  }
2862
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
+
2863
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";
2864
3251
  var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
2865
3252
  var NO_PUSH_URL_MARKER = (remote) => `remote.${remote}.arcaneHadNoPushUrl`;
2866
3253
  var ARCANE_HOOKS_DIR = ".arcane/hooks";
@@ -2951,10 +3338,10 @@ async function repositoryRoot(cwd) {
2951
3338
  return cwd;
2952
3339
  }
2953
3340
  async function hookFilePath(cwd, hookName = "pre-push") {
2954
- return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
3341
+ return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2955
3342
  }
2956
3343
  async function hooksPathValue(cwd) {
2957
- return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
3344
+ return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2958
3345
  }
2959
3346
  async function installHook(cwd, hookName, body) {
2960
3347
  const root = await repositoryRoot(cwd);
@@ -2973,12 +3360,12 @@ async function installHook(cwd, hookName, body) {
2973
3360
  return { status: "refused-default-hooks", hooks: displaced };
2974
3361
  }
2975
3362
  }
2976
- const hookPath = join5(root, ARCANE_HOOKS_DIR, hookName);
3363
+ const hookPath = join6(root, ARCANE_HOOKS_DIR, hookName);
2977
3364
  if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
2978
3365
  return { status: "already-ours" };
2979
3366
  }
2980
- await mkdir5(join5(root, ARCANE_HOOKS_DIR), { recursive: true });
2981
- await writeFile7(hookPath, body, "utf-8");
3367
+ await mkdir5(join6(root, ARCANE_HOOKS_DIR), { recursive: true });
3368
+ await writeFile8(hookPath, body, "utf-8");
2982
3369
  await chmod(hookPath, 493);
2983
3370
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2984
3371
  return { status: "installed", path: hookPath };
@@ -3022,12 +3409,12 @@ async function defaultHooks(cwd) {
3022
3409
  let hooksDir;
3023
3410
  try {
3024
3411
  const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
3025
- hooksDir = join5(resolve2(cwd, stdout.trim()), "hooks");
3412
+ hooksDir = join6(resolve2(cwd, stdout.trim()), "hooks");
3026
3413
  } catch {
3027
3414
  return [];
3028
3415
  }
3029
3416
  try {
3030
- return (await readdir5(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
3417
+ return (await readdir6(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
3031
3418
  } catch {
3032
3419
  return [];
3033
3420
  }
@@ -3058,10 +3445,10 @@ async function removeHook(cwd, hookName) {
3058
3445
  await rm4(await hookFilePath(cwd, hookName), { force: true });
3059
3446
  } catch {
3060
3447
  }
3061
- const hooksDir = join5(root, ARCANE_HOOKS_DIR);
3448
+ const hooksDir = join6(root, ARCANE_HOOKS_DIR);
3062
3449
  let remaining = [];
3063
3450
  try {
3064
- remaining = await readdir5(hooksDir);
3451
+ remaining = await readdir6(hooksDir);
3065
3452
  } catch {
3066
3453
  remaining = [];
3067
3454
  }
@@ -3281,6 +3668,60 @@ function describeError(error) {
3281
3668
  const message = error instanceof Error ? error.message : String(error);
3282
3669
  return message.split("\n")[0].trim();
3283
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
+ }
3284
3725
 
3285
3726
  // src/modules/secrets-scan.ts
3286
3727
  var SECRETS_RULES = [
@@ -3390,6 +3831,13 @@ function buildProfileDescription(components, profile) {
3390
3831
  }
3391
3832
  async function runInit(options, targetDir, assetsDir, packageVersion) {
3392
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
+ }
3393
3841
  await runInitUser(options, targetDir, assetsDir, packageVersion);
3394
3842
  return;
3395
3843
  }
@@ -3398,13 +3846,27 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3398
3846
  `Invalid profile "${options.profile}". Valid options: ${VALID_PROFILES.join(", ")}`
3399
3847
  );
3400
3848
  }
3849
+ let flags = {};
3401
3850
  try {
3402
- await readManifest(targetDir);
3403
- console.log(chalk2.hex("#a855f7")(`
3404
- \u2726 Arcane v${packageVersion}
3405
- `));
3406
- console.log('Already initialized. Run "spell update" to update existing files.');
3407
- return;
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
+ }
3860
+ try {
3861
+ await readManifest(targetDir);
3862
+ console.log(chalk2.hex("#a855f7")(`
3863
+ \u2726 Arcane v${packageVersion}
3864
+ `));
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
+ }
3869
+ return;
3408
3870
  } catch (err) {
3409
3871
  if (!(err instanceof ManifestNotFoundError)) throw err;
3410
3872
  }
@@ -3435,7 +3897,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3435
3897
  );
3436
3898
  printInfo("Running spell init will add new files to your working tree.");
3437
3899
  console.log();
3438
- const continueAnyway = await confirm({
3900
+ const continueAnyway = await confirm2({
3439
3901
  message: "Continue anyway?",
3440
3902
  default: true
3441
3903
  });
@@ -3456,7 +3918,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3456
3918
  let profile = options.profile;
3457
3919
  if (!profile) {
3458
3920
  const profiles = listProfiles();
3459
- profile = await select2({
3921
+ profile = await select3({
3460
3922
  message: "Select an installation profile:\n",
3461
3923
  choices: profiles.map((p) => {
3462
3924
  const profileComponents2 = getProfile(p.id);
@@ -3481,7 +3943,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3481
3943
  }
3482
3944
  if (storeVersion !== void 0) {
3483
3945
  console.log();
3484
- const useTier = await confirm({
3946
+ const useTier = await confirm2({
3485
3947
  message: `This machine has an Arcane user tier (v${storeVersion}). Use it for this repository's spells instead of installing copies here?`,
3486
3948
  default: preferred === "user"
3487
3949
  });
@@ -3504,7 +3966,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3504
3966
  console.log(` ${previewParts.join(" \xB7 ")}`);
3505
3967
  console.log();
3506
3968
  if (!options.dryRun && !options.profile) {
3507
- const proceed = await confirm({
3969
+ const proceed = await confirm2({
3508
3970
  message: "Proceed with installation?",
3509
3971
  default: true
3510
3972
  });
@@ -3519,7 +3981,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3519
3981
  const installedFiles = [];
3520
3982
  const fileHashes = {};
3521
3983
  for (const file of component.files) {
3522
- const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
3984
+ const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
3523
3985
  if (options.dryRun) {
3524
3986
  printDryRun(`Would copy: ${file}`);
3525
3987
  } else {
@@ -3536,7 +3998,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3536
3998
  fileCount++;
3537
3999
  }
3538
4000
  for (const dir of component.directories ?? []) {
3539
- const srcDirPath = join6(assetsDir, dir);
4001
+ const srcDirPath = join7(assetsDir, dir);
3540
4002
  if (options.dryRun) {
3541
4003
  printDryRun(`Would copy directory: ${dir}/`);
3542
4004
  } else {
@@ -3565,7 +4027,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3565
4027
  if (options.dryRun) {
3566
4028
  if (hasAgents) {
3567
4029
  const agentFiles = await import("fs").then(
3568
- (fsModule) => fsModule.promises.readdir(join6(assetsDir, "agents"))
4030
+ (fsModule) => fsModule.promises.readdir(join7(assetsDir, "agents"))
3569
4031
  );
3570
4032
  const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
3571
4033
  printDryRun("Would run agent setup (interactive):");
@@ -3577,10 +4039,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3577
4039
  printDryRun(`Would initialize with profile "${profile}" \u2014 ${fileCount} files`);
3578
4040
  return;
3579
4041
  }
3580
- let role;
3581
- if (!options.profile) {
4042
+ let role = flags.role;
4043
+ if (role === void 0 && !options.profile) {
3582
4044
  console.log();
3583
- const isHub = await confirm({
4045
+ const isHub = await confirm2({
3584
4046
  message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
3585
4047
  default: false
3586
4048
  });
@@ -3588,36 +4050,20 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3588
4050
  }
3589
4051
  let tracking_mode;
3590
4052
  let external_provider;
3591
- 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") {
3592
4057
  tracking_mode = "internal";
3593
4058
  external_provider = null;
3594
4059
  } else if (!options.profile) {
3595
4060
  console.log();
3596
- tracking_mode = await select2({
3597
- message: "How will work be tracked in this repo?",
3598
- choices: [
3599
- { value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
3600
- { value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
3601
- ]
3602
- });
3603
- if (tracking_mode === "external") {
3604
- external_provider = await select2({
3605
- message: "Which external tracker?",
3606
- choices: [
3607
- { value: "ado", name: "Azure DevOps" },
3608
- { value: "github", name: "GitHub Issues" },
3609
- { value: "jira", name: "Jira" },
3610
- { value: "other", name: "Other" }
3611
- ]
3612
- });
3613
- } else {
3614
- external_provider = null;
3615
- }
4061
+ ({ tracking_mode, external_provider } = await askTrackingQuestions(targetDir));
3616
4062
  }
3617
- let subject_root;
3618
- if (profile === "docs" && !options.profile) {
4063
+ let subject_root = flags.subject_root;
4064
+ if (subject_root === void 0 && profile === "docs" && !options.profile) {
3619
4065
  console.log();
3620
- const shape = await select2({
4066
+ const shape = await select3({
3621
4067
  message: "What does this repository hold?",
3622
4068
  choices: [
3623
4069
  {
@@ -3631,7 +4077,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3631
4077
  if (shape === "root") {
3632
4078
  subject_root = ".";
3633
4079
  } else if (shape === "subdir") {
3634
- const answer = await input2({
4080
+ const answer = await input3({
3635
4081
  message: "Directory holding the subject's documents:",
3636
4082
  default: "docs",
3637
4083
  validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
@@ -3641,10 +4087,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3641
4087
  subject_root = null;
3642
4088
  }
3643
4089
  }
3644
- let content_sensitivity;
3645
- if (!options.profile) {
4090
+ let content_sensitivity = flags.content_sensitivity;
4091
+ if (content_sensitivity === void 0 && !options.profile) {
3646
4092
  console.log();
3647
- content_sensitivity = await select2({
4093
+ content_sensitivity = await select3({
3648
4094
  message: "How should agents treat this repository's contents?",
3649
4095
  choices: [
3650
4096
  { value: "standard", name: "Standard \u2014 agents may quote contents in journals and decisions" },
@@ -3656,10 +4102,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3656
4102
  default: "standard"
3657
4103
  });
3658
4104
  }
3659
- let push_policy;
3660
- if (!options.profile) {
4105
+ let push_policy = flags.push_policy;
4106
+ if (push_policy === void 0 && !options.profile) {
3661
4107
  console.log();
3662
- push_policy = await select2({
4108
+ push_policy = await select3({
3663
4109
  message: "Should this repository be allowed to push to a remote?",
3664
4110
  choices: [
3665
4111
  { value: "open", name: "Yes \u2014 normal repository" },
@@ -3686,43 +4132,23 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3686
4132
  };
3687
4133
  await writeManifest(targetDir, manifest);
3688
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
+ }
3689
4140
  if (push_policy === "blocked" || push_policy === "guarded") {
3690
4141
  try {
3691
4142
  if (push_policy === "blocked") {
3692
- const hook = await installPrePushHook(targetDir);
3693
- if (hook.status === "refused-unreadable-config") {
3694
- printWarning(
3695
- "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`."
3696
- );
3697
- } else if (hook.status === "refused-default-hooks") {
3698
- printWarning(
3699
- `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.`
3700
- );
3701
- } else if (hook.status === "refused-foreign-hooks-path") {
3702
- printWarning(
3703
- `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.`
3704
- );
3705
- } else {
3706
- printInfo("Installed a pre-push hook that blocks pushes from this repository.");
3707
- }
3708
- const urls = await disablePushUrls(targetDir);
3709
- const unprotected = urls.filter((u) => u.status === "failed");
3710
- if (unprotected.length > 0) {
3711
- printWarning(
3712
- `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.`
3713
- );
3714
- }
3715
- 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")) {
3716
4147
  printWarning(
3717
- "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.'
3718
4149
  );
3719
- } else {
3720
- const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
3721
- if (covered.length > 0) {
3722
- printInfo(`Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
3723
- }
4150
+ process.exitCode = 1;
3724
4151
  }
3725
- printInfo("Run `spell unblock-push` from a terminal to undo this.");
3726
4152
  } else {
3727
4153
  printInfo(
3728
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."
@@ -3768,7 +4194,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3768
4194
  let agentsConfigured = false;
3769
4195
  if (hasAgents && !options.profile) {
3770
4196
  console.log();
3771
- const setupAgents = await confirm({
4197
+ const setupAgents = await confirm2({
3772
4198
  message: "\u{1F916} Set up your agent team? (names, roles, AI tool configs)",
3773
4199
  default: true
3774
4200
  });
@@ -3814,6 +4240,11 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3814
4240
  '"--profile" does not apply to "--user": the user tier always installs every spell and nothing else.'
3815
4241
  );
3816
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
+ }
3817
4248
  try {
3818
4249
  await readManifest(storeRoot);
3819
4250
  console.log(chalk2.hex("#a855f7")(`
@@ -3847,7 +4278,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3847
4278
  for (const id of spellIds) {
3848
4279
  for (const client of ["codex", "claude"]) {
3849
4280
  const relativePath = USER_FANOUT_PATHS[client](id);
3850
- if (await fileExists(join6(homeDir, relativePath))) {
4281
+ if (await fileExists(join7(homeDir, relativePath))) {
3851
4282
  printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
3852
4283
  } else {
3853
4284
  printDryRun(`Would write: ~/${relativePath}`);
@@ -3865,7 +4296,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3865
4296
  const installedFiles = [];
3866
4297
  const fileHashes = {};
3867
4298
  for (const file of component.files) {
3868
- const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
4299
+ const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
3869
4300
  fileHashes[file] = await copyFile(srcPath, storeRoot, file, { force: options.force });
3870
4301
  installedFiles.push(file);
3871
4302
  fileCount++;
@@ -3911,7 +4342,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3911
4342
  }
3912
4343
 
3913
4344
  // src/commands/add.ts
3914
- import { join as join7 } from "path";
4345
+ import { join as join8 } from "path";
3915
4346
  async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3916
4347
  let manifest;
3917
4348
  try {
@@ -3947,7 +4378,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3947
4378
  const installedFiles = [];
3948
4379
  const fileHashes = {};
3949
4380
  for (const file of component.files) {
3950
- const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
4381
+ const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
3951
4382
  if (options.dryRun) {
3952
4383
  console.log(` [dry-run] Would copy: ${file}`);
3953
4384
  } else {
@@ -3957,7 +4388,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3957
4388
  fileCount++;
3958
4389
  }
3959
4390
  for (const dir of component.directories ?? []) {
3960
- const srcDirPath = join7(assetsDir, dir);
4391
+ const srcDirPath = join8(assetsDir, dir);
3961
4392
  if (options.dryRun) {
3962
4393
  console.log(` [dry-run] Would copy directory: ${dir}/`);
3963
4394
  } else {
@@ -3994,194 +4425,6 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3994
4425
  import { dirname as dirname5, join as join9 } from "path";
3995
4426
  import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
3996
4427
 
3997
- // src/modules/hub.ts
3998
- import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
3999
- import { readdir as readdir6, writeFile as writeFile8 } from "fs/promises";
4000
- import { join as join8 } from "path";
4001
- var MANIFEST_RETROFITS = [
4002
- {
4003
- field: "role",
4004
- needsRetrofit: (m) => m.role === void 0,
4005
- ask: async () => {
4006
- const isHub = await confirm2({
4007
- message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
4008
- default: false
4009
- });
4010
- return { role: isHub ? "hub" : "consumer" };
4011
- }
4012
- },
4013
- {
4014
- field: "tracking_mode",
4015
- needsRetrofit: (m) => m.tracking_mode === void 0,
4016
- // Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
4017
- // profiles get a silent default, full/lite get asked.
4018
- ask: async (manifest) => {
4019
- if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
4020
- return { tracking_mode: "internal", external_provider: null };
4021
- }
4022
- const tracking_mode = await select3({
4023
- message: "How will work be tracked in this repo?",
4024
- choices: [
4025
- { value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
4026
- { value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
4027
- ]
4028
- });
4029
- if (tracking_mode === "external") {
4030
- const external_provider = await select3({
4031
- message: "Which external tracker?",
4032
- choices: [
4033
- { value: "ado", name: "Azure DevOps" },
4034
- { value: "github", name: "GitHub Issues" },
4035
- { value: "jira", name: "Jira" },
4036
- { value: "other", name: "Other" }
4037
- ]
4038
- });
4039
- return { tracking_mode, external_provider };
4040
- }
4041
- return { tracking_mode, external_provider: null };
4042
- }
4043
- },
4044
- {
4045
- field: "content_sensitivity",
4046
- needsRetrofit: (m) => m.content_sensitivity === void 0,
4047
- // EF-12. Asked for every profile -- a code repo can hold sensitive records
4048
- // too -- and defaults to "standard", so an operator who just presses enter
4049
- // keeps today's behaviour exactly.
4050
- ask: async () => {
4051
- const content_sensitivity = await select3({
4052
- message: "How should agents treat this repository's contents?",
4053
- choices: [
4054
- {
4055
- value: "standard",
4056
- name: "Standard \u2014 agents may quote contents in journals and decisions"
4057
- },
4058
- {
4059
- value: "sensitive",
4060
- name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
4061
- }
4062
- ],
4063
- default: "standard"
4064
- });
4065
- return { content_sensitivity };
4066
- }
4067
- },
4068
- {
4069
- field: "subject_root",
4070
- // EF-07. Only the docs profile is asked: other profiles describe code or a
4071
- // venture portfolio, where "what is this repo about" is already answered.
4072
- // A docs install that legitimately holds several subjects answers
4073
- // "portfolio" and stays unset -- so this retrofit deliberately does NOT
4074
- // re-ask on every update. It is gated on the field being absent AND the
4075
- // profile being docs, and once answered (either way) it never fires again,
4076
- // because "portfolio" writes an explicit empty marker.
4077
- needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
4078
- ask: async () => {
4079
- const shape = await select3({
4080
- message: "What does this repository hold?",
4081
- choices: [
4082
- {
4083
- value: "root",
4084
- name: "One subject, at the repository root (documents sit alongside Arcane's files)"
4085
- },
4086
- { value: "subdir", name: "One subject, in its own directory" },
4087
- { value: "portfolio", name: "Several subjects or ventures" }
4088
- ]
4089
- });
4090
- if (shape === "root") return { subject_root: "." };
4091
- if (shape === "subdir") {
4092
- const answer = await input3({
4093
- message: "Directory holding the subject's documents:",
4094
- default: "docs",
4095
- validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
4096
- });
4097
- return { subject_root: answer.trim() || "docs" };
4098
- }
4099
- return { subject_root: null };
4100
- }
4101
- },
4102
- {
4103
- field: "push_policy",
4104
- needsRetrofit: (m) => m.push_policy === void 0,
4105
- // EF-09. Defaults to "open", so an operator who just presses enter keeps
4106
- // today's behaviour exactly. NOTE: this only records the choice -- unlike
4107
- // init, the retrofit does not install the hook or disable the push URL,
4108
- // because an update should not silently start blocking pushes in a repo
4109
- // someone is mid-workflow in. doctor reports the gap instead.
4110
- ask: async () => {
4111
- const push_policy = await select3({
4112
- message: "Should this repository be allowed to push to a remote?",
4113
- choices: [
4114
- { value: "open", name: "Yes \u2014 normal repository" },
4115
- { value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
4116
- { value: "blocked", name: "No \u2014 block pushes" }
4117
- ],
4118
- default: "open"
4119
- });
4120
- return { push_policy };
4121
- }
4122
- }
4123
- ];
4124
- async function runManifestRetrofits(manifest) {
4125
- const applicable = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
4126
- if (applicable.length === 0) return {};
4127
- console.log();
4128
- console.log(
4129
- `This install predates ${applicable.length} manifest field${applicable.length === 1 ? "" : "s"} -- a couple of quick questions:`
4130
- );
4131
- let patch = {};
4132
- for (const retrofit of applicable) {
4133
- const answer = await retrofit.ask({ ...manifest, ...patch });
4134
- patch = { ...patch, ...answer };
4135
- }
4136
- return patch;
4137
- }
4138
- async function offerRegistryScaffold(targetDir, businessRoot) {
4139
- const registryPath = join8(targetDir, businessRoot, "registry.json");
4140
- if (await fileExists(registryPath)) return;
4141
- const businessRootPath = join8(targetDir, businessRoot);
4142
- let ventureDirs;
4143
- try {
4144
- const entries = await readdir6(businessRootPath, { withFileTypes: true });
4145
- ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
4146
- } catch {
4147
- return;
4148
- }
4149
- if (ventureDirs.length === 0) return;
4150
- console.log();
4151
- const scaffold = await confirm2({
4152
- message: `Found ${ventureDirs.length} folder${ventureDirs.length === 1 ? "" : "s"} under ${businessRoot}/ -- scaffold ${businessRoot}/registry.json from them?`,
4153
- default: true
4154
- });
4155
- if (!scaffold) return;
4156
- const ventures = {};
4157
- for (const slug of ventureDirs) {
4158
- const include = await confirm2({ message: ` Include "${slug}"?`, default: true });
4159
- if (!include) continue;
4160
- ventures[slug] = {
4161
- name: slug,
4162
- aliases: [],
4163
- status: "active",
4164
- visibility: "private",
4165
- ownership: "llc",
4166
- tracking: "none",
4167
- repos: []
4168
- };
4169
- }
4170
- if (Object.keys(ventures).length === 0) {
4171
- console.log(" No ventures selected -- registry not created.");
4172
- return;
4173
- }
4174
- const registry = {
4175
- _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.",
4176
- updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
4177
- ventures
4178
- };
4179
- await writeFile8(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
4180
- console.log(
4181
- ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
4182
- );
4183
- }
4184
-
4185
4428
  // src/modules/merge3.ts
4186
4429
  var CONFLICT_START = "<<<<<<< yours (edited)";
4187
4430
  var CONFLICT_MID = "=======";
@@ -4345,6 +4588,14 @@ async function missingTierWarning(scope, spellScope, homeDir) {
4345
4588
  ];
4346
4589
  }
4347
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
+ }
4348
4599
  function defaultScopePatch(options, manifest) {
4349
4600
  if (options.defaultScope === void 0) return void 0;
4350
4601
  if (manifest.default_spell_scope === options.defaultScope) return void 0;
@@ -4353,6 +4604,9 @@ function defaultScopePatch(options, manifest) {
4353
4604
  function migrateLegacyComponents(components) {
4354
4605
  const out = [];
4355
4606
  const seen = /* @__PURE__ */ new Set();
4607
+ const inheritedFiles = [];
4608
+ const inheritedHashes = {};
4609
+ let carrier = null;
4356
4610
  for (const entry of components) {
4357
4611
  const replacements = LEGACY_COMPONENT_MIGRATIONS[entry.name];
4358
4612
  if (!replacements) {
@@ -4361,13 +4615,24 @@ function migrateLegacyComponents(components) {
4361
4615
  out.push(entry);
4362
4616
  continue;
4363
4617
  }
4618
+ for (const file of entry.files) {
4619
+ if (!inheritedFiles.includes(file)) inheritedFiles.push(file);
4620
+ const hash = entry.fileHashes?.[file];
4621
+ if (hash !== void 0 && inheritedHashes[file] === void 0) {
4622
+ inheritedHashes[file] = hash;
4623
+ }
4624
+ }
4364
4625
  for (const name of replacements) {
4626
+ carrier ??= name;
4365
4627
  if (seen.has(name)) continue;
4366
4628
  seen.add(name);
4367
4629
  out.push({ name, files: [], installedVersion: entry.installedVersion });
4368
4630
  }
4369
4631
  }
4370
- return out;
4632
+ return {
4633
+ components: out,
4634
+ inherited: { files: inheritedFiles, fileHashes: inheritedHashes, carrier }
4635
+ };
4371
4636
  }
4372
4637
  async function resolveOrphan(targetDir, file, recordedHash, prune) {
4373
4638
  try {
@@ -4392,6 +4657,19 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
4392
4657
  console.log(` Pruned orphaned file: ${file}`);
4393
4658
  return "pruned";
4394
4659
  }
4660
+ async function retainReportedOrphan(targetDir, file, recordedHash, into) {
4661
+ try {
4662
+ validateTargetPath(targetDir, file);
4663
+ } catch {
4664
+ return;
4665
+ }
4666
+ if (!await fileExists(join9(targetDir, file))) return;
4667
+ if (!into.files.includes(file)) into.files.push(file);
4668
+ if (recordedHash !== void 0) {
4669
+ into.fileHashes ??= {};
4670
+ into.fileHashes[file] = recordedHash;
4671
+ }
4672
+ }
4395
4673
  function findReadoptedSpellComponents(components, scope = "repo", spellScope = "repo") {
4396
4674
  if (scope !== "repo" || spellScope !== "repo") return /* @__PURE__ */ new Set();
4397
4675
  return new Set(
@@ -4460,6 +4738,27 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4460
4738
  return;
4461
4739
  }
4462
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 };
4463
4762
  const spellScope = effectiveSpellScope(manifest);
4464
4763
  const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
4465
4764
  const homeDir = dirname5(targetDir);
@@ -4500,6 +4799,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4500
4799
  const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
4501
4800
  if (missing.length === 0 && unmanaged.length === 0) {
4502
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
+ }
4503
4806
  if (scope === "user") {
4504
4807
  const fanout = await syncUserTierFanout({
4505
4808
  ownedClients: SPELL_FANOUT_CLIENTS,
@@ -4532,6 +4835,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4532
4835
  }
4533
4836
  if (manifest.components.length === 0) {
4534
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
+ }
4535
4842
  return;
4536
4843
  }
4537
4844
  let fileCount = 0;
@@ -4539,7 +4846,17 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4539
4846
  const conflictedFiles = [];
4540
4847
  const customizedShims = [];
4541
4848
  const orphanReport = [];
4542
- const componentsToUpdate = migrateLegacyComponents(manifest.components);
4849
+ const orphanCandidates = [];
4850
+ const { components: componentsToUpdate, inherited } = migrateLegacyComponents(manifest.components);
4851
+ if (inherited.carrier !== null) {
4852
+ for (const file of inherited.files) {
4853
+ orphanCandidates.push({
4854
+ owner: inherited.carrier,
4855
+ file,
4856
+ recordedHash: inherited.fileHashes[file]
4857
+ });
4858
+ }
4859
+ }
4543
4860
  for (const installed of componentsToUpdate) {
4544
4861
  let component;
4545
4862
  try {
@@ -4658,24 +4975,11 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4658
4975
  }
4659
4976
  for (const file of installed.files) {
4660
4977
  if (updatedFiles.includes(file)) continue;
4661
- const recordedHash = installed.fileHashes?.[file];
4662
- const status = await resolveOrphan(
4663
- targetDir,
4978
+ orphanCandidates.push({
4979
+ owner: installed.name,
4664
4980
  file,
4665
- recordedHash,
4666
- Boolean(options.prune) && !options.dryRun
4667
- );
4668
- orphanReport.push({ file, status });
4669
- if (status === "reported") {
4670
- try {
4671
- validateTargetPath(targetDir, file);
4672
- if (await fileExists(join9(targetDir, file))) {
4673
- updatedFiles.push(file);
4674
- if (recordedHash !== void 0) fileHashes[file] = recordedHash;
4675
- }
4676
- } catch {
4677
- }
4678
- }
4981
+ recordedHash: installed.fileHashes?.[file]
4982
+ });
4679
4983
  }
4680
4984
  updatedComponents.push({
4681
4985
  ...installed,
@@ -4684,6 +4988,26 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4684
4988
  fileHashes
4685
4989
  });
4686
4990
  }
4991
+ const claimed = new Set(updatedComponents.flatMap((c) => c.files));
4992
+ const resolvedOrphans = /* @__PURE__ */ new Set();
4993
+ for (const { owner, file, recordedHash } of orphanCandidates) {
4994
+ if (claimed.has(file) || resolvedOrphans.has(file)) continue;
4995
+ resolvedOrphans.add(file);
4996
+ const status = await resolveOrphan(
4997
+ targetDir,
4998
+ file,
4999
+ recordedHash,
5000
+ Boolean(options.prune) && !options.dryRun
5001
+ );
5002
+ orphanReport.push({ file, status });
5003
+ if (status !== "reported") continue;
5004
+ let into = updatedComponents.find((c) => c.name === owner);
5005
+ if (!into) {
5006
+ into = { name: owner, files: [], installedVersion: packageVersion, fileHashes: {} };
5007
+ updatedComponents.push(into);
5008
+ }
5009
+ await retainReportedOrphan(targetDir, file, recordedHash, into);
5010
+ }
4687
5011
  const orphansToReport = orphanReport.filter((o) => o.status !== "not-found");
4688
5012
  if (orphansToReport.length > 0) {
4689
5013
  const verb = options.dryRun ? "[dry-run] Found" : "Found";
@@ -4736,7 +5060,8 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4736
5060
  `
4737
5061
  [dry-run] Would update ${fileCount} files.`
4738
5062
  );
4739
- 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)) : [];
4740
5065
  if (applicableRetrofits.length > 0) {
4741
5066
  console.log(
4742
5067
  `[dry-run] Would ask ${applicableRetrofits.length} manifest retrofit question${applicableRetrofits.length === 1 ? "" : "s"}: ${applicableRetrofits.map((r) => r.field).join(", ")}.`
@@ -4745,19 +5070,21 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4745
5070
  return;
4746
5071
  }
4747
5072
  const interactive = Boolean(process.stdin.isTTY) && scope === "repo";
4748
- const retrofitPatch = interactive ? await runManifestRetrofits(manifest) : {};
5073
+ const retrofitPatch = interactive ? await runManifestRetrofits(answered, targetDir) : {};
4749
5074
  if (!interactive && scope === "repo") {
4750
- const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
5075
+ const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(answered));
4751
5076
  if (pending.length > 0) {
4752
5077
  console.log(
4753
- ` ! 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:`
4754
5079
  );
5080
+ for (const line of manifestFlagLine(answered) ?? []) console.log(` ${line}`);
4755
5081
  }
4756
5082
  }
4757
5083
  const updated = {
4758
5084
  ...manifest,
4759
5085
  version: packageVersion,
4760
5086
  components: updatedComponents,
5087
+ ...flagPatch,
4761
5088
  ...retrofitPatch,
4762
5089
  ...fanoutRecord !== void 0 ? { fanout: fanoutRecord } : {},
4763
5090
  ...defaultScopePatch(options, manifest)
@@ -4767,6 +5094,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4767
5094
  `
4768
5095
  \u2713 Updated ${fileCount} files.`
4769
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
+ }
4770
5102
  if (conflictedFiles.length > 0) {
4771
5103
  console.log(
4772
5104
  `
@@ -4776,7 +5108,13 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4776
5108
  );
4777
5109
  }
4778
5110
  if (scope === "repo" && manifest.role !== "hub" && updated.role === "hub") {
4779
- 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
+ }
4780
5118
  }
4781
5119
  }
4782
5120
 
@@ -5187,10 +5525,8 @@ async function uninstallUserTier(options, storeRoot, manifest) {
5187
5525
  }
5188
5526
 
5189
5527
  // src/commands/doctor.ts
5190
- import { access as access4, readdir as readdir8, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
5191
- import { join as join13, dirname as dirname9 } from "path";
5192
- import { execFile as execFile3 } from "child_process";
5193
- import { promisify as promisify2 } from "util";
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";
5194
5530
 
5195
5531
  // src/config/incidents.ts
5196
5532
  var INCIDENT_QUEUE = [
@@ -5331,10 +5667,33 @@ async function scanRepository(repoRoot, rules, extensions, excludePrefixes = [])
5331
5667
  return findings;
5332
5668
  }
5333
5669
 
5334
- // src/modules/platform-policy.ts
5670
+ // src/modules/exec.ts
5335
5671
  import { execFile as execFile2 } from "child_process";
5336
- import { promisify } from "util";
5337
- var execFileAsync = promisify(execFile2);
5672
+ var EXTERNAL_CLI_TIMEOUT_MS = 1e4;
5673
+ function execFileWithTimeout(file, args, timeoutMs) {
5674
+ return new Promise((resolve5, reject) => {
5675
+ const child = execFile2(
5676
+ file,
5677
+ args,
5678
+ {
5679
+ timeout: timeoutMs,
5680
+ killSignal: "SIGTERM",
5681
+ windowsHide: true,
5682
+ encoding: "utf8"
5683
+ },
5684
+ (error, stdout, stderr) => {
5685
+ if (error) {
5686
+ reject(error);
5687
+ return;
5688
+ }
5689
+ resolve5({ stdout, stderr });
5690
+ }
5691
+ );
5692
+ child.stdin?.end();
5693
+ });
5694
+ }
5695
+
5696
+ // src/modules/platform-policy.ts
5338
5697
  var SANCTIONED_MERGE_METHODS = ["merge", "rebase"];
5339
5698
  function compareToLadder(effective, declaredButBlocked, interactionNote) {
5340
5699
  const disallowedButPermitted = effective.filter(
@@ -5401,17 +5760,19 @@ function evaluateGitHubMergePolicy(rulesets) {
5401
5760
  }
5402
5761
  async function fetchGitHubRulesets(owner, repo) {
5403
5762
  try {
5404
- const { stdout: listOut } = await execFileAsync("gh", [
5405
- "api",
5406
- `repos/${owner}/${repo}/rulesets`
5407
- ]);
5763
+ const { stdout: listOut } = await execFileWithTimeout(
5764
+ "gh",
5765
+ ["api", `repos/${owner}/${repo}/rulesets`],
5766
+ EXTERNAL_CLI_TIMEOUT_MS
5767
+ );
5408
5768
  const summaries = JSON.parse(listOut);
5409
5769
  const detailed = await Promise.all(
5410
5770
  summaries.map(async (s) => {
5411
- const { stdout } = await execFileAsync("gh", [
5412
- "api",
5413
- `repos/${owner}/${repo}/rulesets/${s.id}`
5414
- ]);
5771
+ const { stdout } = await execFileWithTimeout(
5772
+ "gh",
5773
+ ["api", `repos/${owner}/${repo}/rulesets/${s.id}`],
5774
+ EXTERNAL_CLI_TIMEOUT_MS
5775
+ );
5415
5776
  return JSON.parse(stdout);
5416
5777
  })
5417
5778
  );
@@ -5443,7 +5804,7 @@ function evaluateAdoMergePolicy(policies) {
5443
5804
  async function fetchAdoMergeTypePolicies(org, project, repoName, branch) {
5444
5805
  try {
5445
5806
  const orgUrl = `https://dev.azure.com/${org}`;
5446
- const { stdout: repoOut } = await execFileAsync("az", [
5807
+ const { stdout: repoOut } = await execFileWithTimeout("az", [
5447
5808
  "repos",
5448
5809
  "show",
5449
5810
  "--repository",
@@ -5456,10 +5817,10 @@ async function fetchAdoMergeTypePolicies(org, project, repoName, branch) {
5456
5817
  "id",
5457
5818
  "--output",
5458
5819
  "tsv"
5459
- ]);
5820
+ ], EXTERNAL_CLI_TIMEOUT_MS);
5460
5821
  const repositoryId = repoOut.trim();
5461
5822
  if (!repositoryId) return null;
5462
- const { stdout } = await execFileAsync("az", [
5823
+ const { stdout } = await execFileWithTimeout("az", [
5463
5824
  "repos",
5464
5825
  "policy",
5465
5826
  "list",
@@ -5473,7 +5834,7 @@ async function fetchAdoMergeTypePolicies(org, project, repoName, branch) {
5473
5834
  project,
5474
5835
  "--output",
5475
5836
  "json"
5476
- ]);
5837
+ ], EXTERNAL_CLI_TIMEOUT_MS);
5477
5838
  return JSON.parse(stdout);
5478
5839
  } catch {
5479
5840
  return null;
@@ -5500,8 +5861,134 @@ function parseAdoRemote(url) {
5500
5861
  return null;
5501
5862
  }
5502
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
+
5503
5991
  // src/commands/doctor.ts
5504
- var execFileAsync2 = promisify2(execFile3);
5505
5992
  function isRecord3(value) {
5506
5993
  return typeof value === "object" && value !== null && !Array.isArray(value);
5507
5994
  }
@@ -5518,52 +6005,45 @@ async function checkNodeVersion() {
5518
6005
  message: `${raw} \u2014 Node.js 18 or later is required`
5519
6006
  };
5520
6007
  }
5521
- async function checkVSCodeExtension(id, label) {
6008
+ async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
5522
6009
  const name = `VS Code extension: ${label}`;
5523
- const home = process.env["HOME"] ?? "";
5524
- const extDirs = [
5525
- join13(home, ".vscode", "extensions"),
5526
- join13(home, ".vscode-insiders", "extensions")
6010
+ const flavours = [
6011
+ { dir: join14(homeDir, ".vscode", "extensions"), bin: "code" },
6012
+ { dir: join14(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
5527
6013
  ];
5528
- for (const dir of extDirs) {
6014
+ let installBin = null;
6015
+ for (const flavour of flavours) {
6016
+ let entries;
5529
6017
  try {
5530
- const { readdir: readdir11 } = await import("fs/promises");
5531
- const entries = await readdir11(dir);
5532
- const prefix = id.toLowerCase() + "-";
5533
- const found = entries.some(
5534
- (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
5535
- );
5536
- if (found) {
5537
- return { name, passed: true, message: `${id} is installed` };
5538
- }
6018
+ entries = await readdir9(flavour.dir);
5539
6019
  } catch {
6020
+ continue;
5540
6021
  }
5541
- }
5542
- let installBin = "code";
5543
- for (const bin of ["code", "code-insiders"]) {
5544
- try {
5545
- await execFileAsync2(bin, ["--version"]);
5546
- installBin = bin;
5547
- break;
5548
- } catch {
6022
+ installBin ??= flavour.bin;
6023
+ const prefix = id.toLowerCase() + "-";
6024
+ const found = entries.some(
6025
+ (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
6026
+ );
6027
+ if (found) {
6028
+ return { name, passed: true, message: `${id} is installed` };
5549
6029
  }
5550
6030
  }
5551
6031
  return {
5552
6032
  name,
5553
6033
  passed: false,
5554
- message: `${id} not found. Install via: ${installBin} --install-extension ${id}`,
6034
+ message: `${id} not found. Install via: ${installBin ?? "code"} --install-extension ${id}`,
5555
6035
  blocking: false
5556
6036
  };
5557
6037
  }
5558
6038
  async function checkArcaneManifest(targetDir) {
5559
6039
  const name = "Arcane manifest (.arcane.json)";
5560
- const manifestPath2 = join13(targetDir, ".arcane.json");
6040
+ const manifestPath2 = join14(targetDir, ".arcane.json");
5561
6041
  try {
5562
6042
  await access4(manifestPath2);
5563
6043
  } catch {
5564
- const selfHostedManifestPath = join13(targetDir, "src", "assets", ".arcane.json");
6044
+ const selfHostedManifestPath = join14(targetDir, "src", "assets", ".arcane.json");
5565
6045
  try {
5566
- const raw = await readFile10(selfHostedManifestPath, "utf8");
6046
+ const raw = await readFile11(selfHostedManifestPath, "utf8");
5567
6047
  const manifest = JSON.parse(raw);
5568
6048
  if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
5569
6049
  return {
@@ -5582,7 +6062,7 @@ async function checkArcaneManifest(targetDir) {
5582
6062
  };
5583
6063
  }
5584
6064
  try {
5585
- const raw = await readFile10(manifestPath2, "utf8");
6065
+ const raw = await readFile11(manifestPath2, "utf8");
5586
6066
  const manifest = JSON.parse(raw);
5587
6067
  if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
5588
6068
  return {
@@ -5667,7 +6147,7 @@ async function checkSessionContinuity(targetDir) {
5667
6147
  const results = [];
5668
6148
  for (const file of SESSION_CONTINUITY_FILES) {
5669
6149
  const name = `Session file: ${file}`;
5670
- const filePath = join13(targetDir, file);
6150
+ const filePath = join14(targetDir, file);
5671
6151
  try {
5672
6152
  await access4(filePath);
5673
6153
  results.push({ name, passed: true, message: "present" });
@@ -5685,11 +6165,11 @@ async function checkSessionContinuity(targetDir) {
5685
6165
  async function fixSessionContinuity(targetDir, assetsDir) {
5686
6166
  const created = [];
5687
6167
  for (const file of SESSION_CONTINUITY_FILES) {
5688
- const destPath = join13(targetDir, file);
6168
+ const destPath = join14(targetDir, file);
5689
6169
  try {
5690
6170
  await access4(destPath);
5691
6171
  } catch {
5692
- const srcPath = join13(assetsDir, file);
6172
+ const srcPath = join14(assetsDir, file);
5693
6173
  await mkdir7(dirname9(destPath), { recursive: true });
5694
6174
  await fsCopyFile(srcPath, destPath);
5695
6175
  created.push(file);
@@ -5809,15 +6289,48 @@ async function checkPushPolicy(targetDir) {
5809
6289
  name,
5810
6290
  passed: false,
5811
6291
  blocking: false,
5812
- 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\`.`
5813
6326
  };
5814
6327
  }
5815
6328
  async function checkMcpConfig(targetDir) {
5816
6329
  const name = "MCP config (per-server timeout, I12)";
5817
- const filePath = join13(targetDir, ".mcp.json");
6330
+ const filePath = join14(targetDir, ".mcp.json");
5818
6331
  let raw;
5819
6332
  try {
5820
- raw = await readFile10(filePath, "utf8");
6333
+ raw = await readFile11(filePath, "utf8");
5821
6334
  } catch {
5822
6335
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
5823
6336
  }
@@ -5870,10 +6383,10 @@ function isDelegationsFile(value) {
5870
6383
  }
5871
6384
  async function checkDelegations(targetDir) {
5872
6385
  const name = "Standing delegations (T13)";
5873
- const filePath = join13(targetDir, ".arcane", "delegations.json");
6386
+ const filePath = join14(targetDir, ".arcane", "delegations.json");
5874
6387
  let raw;
5875
6388
  try {
5876
- raw = await readFile10(filePath, "utf8");
6389
+ raw = await readFile11(filePath, "utf8");
5877
6390
  } catch {
5878
6391
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
5879
6392
  }
@@ -5983,7 +6496,7 @@ async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
5983
6496
  let probed = false;
5984
6497
  const probe = "spell-status.md";
5985
6498
  try {
5986
- const entries = await readdir8(join13(storeRoot, USER_TIER_SPELLS_DIR));
6499
+ const entries = await readdir9(join14(storeRoot, USER_TIER_SPELLS_DIR));
5987
6500
  const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
5988
6501
  spellCount = spells.length;
5989
6502
  probed = spells.includes(probe);
@@ -6037,7 +6550,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6037
6550
  }
6038
6551
  const results = await Promise.all([
6039
6552
  checkNodeVersion(),
6040
- checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
6553
+ checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)", resolveHomeDir()),
6041
6554
  checkArcaneManifest(targetDir),
6042
6555
  Promise.resolve(checkIncidentReleaseGate()),
6043
6556
  checkPullRebase(targetDir),
@@ -6046,16 +6559,18 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6046
6559
  checkDelegations(targetDir),
6047
6560
  checkMcpConfig(targetDir),
6048
6561
  checkUserTier(resolveHomeDir(), options.packageVersion),
6049
- checkSpellScope(targetDir)
6562
+ checkSpellScope(targetDir),
6563
+ checkGovernancePlaceholders(targetDir)
6050
6564
  ]);
6051
6565
  const sessionResults = await checkSessionContinuity(targetDir);
6052
6566
  results.push(...sessionResults);
6053
6567
  let allPassed = true;
6054
6568
  for (const result of results) {
6055
- const icon = result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
6056
- 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";
6057
6572
  console.log(` ${icon} [${label}] ${result.name}`);
6058
- if (!result.passed) {
6573
+ if (!result.passed || skipped) {
6059
6574
  console.log(` ${result.message}`);
6060
6575
  }
6061
6576
  if (!result.passed && result.blocking !== false) {
@@ -6067,40 +6582,38 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6067
6582
  console.log(" All checks passed. Your environment is ready.\n");
6068
6583
  } else {
6069
6584
  console.log(" One or more checks failed. Fix the issues above before proceeding.\n");
6070
- const hasMissingSession = sessionResults.some((r) => !r.passed);
6071
- if (options.fix && hasMissingSession && assetsDir) {
6072
- console.log(" \u{1F527} Fixing missing session continuity files...\n");
6073
- const created = await fixSessionContinuity(targetDir, assetsDir);
6074
- if (created.length > 0) {
6075
- for (const file of created) {
6076
- console.log(` \u2713 Created: ${file}`);
6077
- }
6078
- console.log(`
6585
+ process.exitCode = 1;
6586
+ }
6587
+ const hasMissingSession = sessionResults.some((r) => !r.passed);
6588
+ if (options.fix && hasMissingSession && assetsDir) {
6589
+ console.log(" \u{1F527} Fixing missing session continuity files...\n");
6590
+ const created = await fixSessionContinuity(targetDir, assetsDir);
6591
+ if (created.length > 0) {
6592
+ for (const file of created) {
6593
+ console.log(` \u2713 Created: ${file}`);
6594
+ }
6595
+ console.log(`
6079
6596
  ${created.length} file(s) created. Repo is now close-session-ready.
6080
6597
  `);
6081
- } else {
6082
- console.log(" No files needed creation (all present).\n");
6083
- }
6084
- } else if (hasMissingSession && !options.fix) {
6085
- console.log(" \u{1F4A1} Run `spell doctor --fix` to create missing session files.\n");
6086
- }
6087
- if (!allPassed) {
6088
- process.exitCode = 1;
6598
+ } else {
6599
+ console.log(" No files needed creation (all present).\n");
6089
6600
  }
6601
+ } else if (hasMissingSession && !options.fix) {
6602
+ console.log(" \u{1F4A1} Run `spell doctor --fix` to create missing session files.\n");
6090
6603
  }
6091
6604
  }
6092
6605
 
6093
6606
  // src/commands/ward.ts
6094
- import { readFile as readFile12 } from "fs/promises";
6607
+ import { readFile as readFile13 } from "fs/promises";
6095
6608
 
6096
6609
  // src/modules/ward.ts
6097
- import { readdir as readdir9, readFile as readFile11 } from "fs/promises";
6098
- 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";
6099
6612
  async function scanFileAllRules(absolutePath, displayPath, rules) {
6100
6613
  const findings = [];
6101
6614
  let content;
6102
6615
  try {
6103
- content = await readFile11(absolutePath, "utf8");
6616
+ content = await readFile12(absolutePath, "utf8");
6104
6617
  } catch {
6105
6618
  return findings;
6106
6619
  }
@@ -6153,12 +6666,12 @@ async function scanFilenames(root, rules, repoRoot = root) {
6153
6666
  const findings = [];
6154
6667
  let entries;
6155
6668
  try {
6156
- entries = await readdir9(root, { withFileTypes: true });
6669
+ entries = await readdir10(root, { withFileTypes: true });
6157
6670
  } catch {
6158
6671
  return findings;
6159
6672
  }
6160
6673
  for (const entry of entries) {
6161
- const absolutePath = join14(root, entry.name);
6674
+ const absolutePath = join15(root, entry.name);
6162
6675
  if (entry.isDirectory()) {
6163
6676
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
6164
6677
  findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
@@ -6195,7 +6708,7 @@ async function runWard(repoRoot, options = {}) {
6195
6708
  const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
6196
6709
  if (findings2.length > 0) {
6197
6710
  try {
6198
- fileLines.set(displayPath, (await readFile11(absolutePath, "utf8")).split("\n"));
6711
+ fileLines.set(displayPath, (await readFile12(absolutePath, "utf8")).split("\n"));
6199
6712
  } catch {
6200
6713
  }
6201
6714
  }
@@ -6223,7 +6736,7 @@ function parseTermsFileContent(raw) {
6223
6736
  }
6224
6737
  async function readTermsFile(filePath) {
6225
6738
  try {
6226
- return parseTermsFileContent(await readFile12(filePath, "utf8"));
6739
+ return parseTermsFileContent(await readFile13(filePath, "utf8"));
6227
6740
  } catch (error) {
6228
6741
  console.warn(
6229
6742
  `\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
@@ -6370,17 +6883,80 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
6370
6883
  );
6371
6884
  }
6372
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
+
6373
6949
  // src/commands/report.ts
6374
6950
  import { access as access5 } from "fs/promises";
6375
- import { join as join17 } from "path";
6951
+ import { join as join18 } from "path";
6376
6952
 
6377
6953
  // src/modules/show-report/generate.ts
6378
- import { mkdir as mkdir8, readdir as readdir10, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
6379
- 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";
6380
6956
 
6381
6957
  // src/modules/show-report/model.ts
6382
- import { readFile as readFile13 } from "fs/promises";
6383
- import { join as join15 } from "path";
6958
+ import { readFile as readFile14 } from "fs/promises";
6959
+ import { join as join16 } from "path";
6384
6960
 
6385
6961
  // src/modules/show-report/plan-parser.ts
6386
6962
  function parseFrontmatter(content) {
@@ -6650,7 +7226,7 @@ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
6650
7226
  // src/modules/show-report/model.ts
6651
7227
  async function readOptional(path4) {
6652
7228
  try {
6653
- return await readFile13(path4, "utf8");
7229
+ return await readFile14(path4, "utf8");
6654
7230
  } catch {
6655
7231
  return null;
6656
7232
  }
@@ -6746,13 +7322,13 @@ function buildCorrections(ledgerRows, programName) {
6746
7322
  };
6747
7323
  }
6748
7324
  async function buildShowReportModel(options) {
6749
- const planAbsPath = join15(options.rootDir, options.planRelPath);
6750
- const queueAbsPath = join15(options.rootDir, options.queueRelPath);
7325
+ const planAbsPath = join16(options.rootDir, options.planRelPath);
7326
+ const queueAbsPath = join16(options.rootDir, options.queueRelPath);
6751
7327
  const ledgerRelPath = "docs/verification-ledger.md";
6752
- const ledgerAbsPath = join15(options.rootDir, ledgerRelPath);
6753
- const decisionsAbsPath = join15(options.rootDir, "DECISIONS.md");
7328
+ const ledgerAbsPath = join16(options.rootDir, ledgerRelPath);
7329
+ const decisionsAbsPath = join16(options.rootDir, "DECISIONS.md");
6754
7330
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
6755
- readFile13(planAbsPath, "utf8"),
7331
+ readFile14(planAbsPath, "utf8"),
6756
7332
  readOptional(queueAbsPath),
6757
7333
  readOptional(ledgerAbsPath),
6758
7334
  readOptional(decisionsAbsPath)
@@ -6921,7 +7497,7 @@ function toPosix(path4) {
6921
7497
  async function programFromPlan(rootDir, slug, planRelPath) {
6922
7498
  let planContent;
6923
7499
  try {
6924
- planContent = await readFile14(join16(rootDir, planRelPath), "utf8");
7500
+ planContent = await readFile15(join17(rootDir, planRelPath), "utf8");
6925
7501
  } catch {
6926
7502
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
6927
7503
  }
@@ -6933,16 +7509,16 @@ async function programFromPlan(rootDir, slug, planRelPath) {
6933
7509
  program: {
6934
7510
  slug,
6935
7511
  planRelPath,
6936
- queueRelPath: toPosix(join16(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
7512
+ queueRelPath: toPosix(join17(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
6937
7513
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
6938
7514
  }
6939
7515
  };
6940
7516
  }
6941
7517
  async function discoverPrograms(rootDir) {
6942
- const plansDir = join16(rootDir, "docs", "plans");
7518
+ const plansDir = join17(rootDir, "docs", "plans");
6943
7519
  let entries;
6944
7520
  try {
6945
- entries = (await readdir10(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
7521
+ entries = (await readdir11(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6946
7522
  } catch (error) {
6947
7523
  if (error.code === "ENOENT") return { programs: [], skipped: [] };
6948
7524
  throw error;
@@ -6966,14 +7542,14 @@ function normalizeLineEndings2(content) {
6966
7542
  }
6967
7543
  async function readOptionalFile(path4) {
6968
7544
  try {
6969
- return await readFile14(path4, "utf8");
7545
+ return await readFile15(path4, "utf8");
6970
7546
  } catch (error) {
6971
7547
  if (error.code === "ENOENT") return null;
6972
7548
  throw error;
6973
7549
  }
6974
7550
  }
6975
7551
  async function generateReport(rootDir, program2, templateHtml) {
6976
- const planContent = await readFile14(join16(rootDir, program2.planRelPath), "utf8");
7552
+ const planContent = await readFile15(join17(rootDir, program2.planRelPath), "utf8");
6977
7553
  const frontmatter = parseFrontmatter(planContent);
6978
7554
  const model = await buildShowReportModel({
6979
7555
  rootDir,
@@ -6991,7 +7567,7 @@ async function generateReport(rootDir, program2, templateHtml) {
6991
7567
  };
6992
7568
  }
6993
7569
  async function runReportGeneration(options) {
6994
- const templateHtml = await readFile14(options.templatePath, "utf8");
7570
+ const templateHtml = await readFile15(options.templatePath, "utf8");
6995
7571
  const drifted = [];
6996
7572
  const repaired = [];
6997
7573
  const unwritten = [];
@@ -7006,12 +7582,12 @@ async function runReportGeneration(options) {
7006
7582
  [`${outRel}/show-report.html`, generated.html]
7007
7583
  ];
7008
7584
  for (const [relPath, expected] of outputs) {
7009
- const actual = await readOptionalFile(join16(options.rootDir, relPath));
7585
+ const actual = await readOptionalFile(join17(options.rootDir, relPath));
7010
7586
  if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
7011
7587
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
7012
7588
  if (options.mode === "write") {
7013
7589
  await mkdir8(outAbs, { recursive: true });
7014
- await writeFile11(join16(options.rootDir, relPath), expected, "utf8");
7590
+ await writeFile11(join17(options.rootDir, relPath), expected, "utf8");
7015
7591
  repaired.push(relPath);
7016
7592
  }
7017
7593
  }
@@ -7022,7 +7598,7 @@ async function runReportGeneration(options) {
7022
7598
 
7023
7599
  // src/commands/report.ts
7024
7600
  async function runReport(targetDir, options = {}, assetsDir) {
7025
- const templatePath = join17(assetsDir, "report", "show-report.template.html");
7601
+ const templatePath = join18(assetsDir, "report", "show-report.template.html");
7026
7602
  try {
7027
7603
  await access5(templatePath);
7028
7604
  } catch {
@@ -7085,13 +7661,29 @@ async function runReport(targetDir, options = {}, assetsDir) {
7085
7661
  import { realpathSync } from "fs";
7086
7662
  var require3 = createRequire2(import.meta.url);
7087
7663
  var pkg = require3("../package.json");
7088
- 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");
7089
7665
  var program = new Command();
7090
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)";
7091
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)";
7092
7668
  function targetDirFor(opts) {
7093
7669
  return opts.user ? userTierRoot() : process.cwd();
7094
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
+ }
7095
7687
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
7096
7688
  if (program.args.length > 0) {
7097
7689
  const [attempted] = program.args;
@@ -7111,14 +7703,17 @@ program.name("spell").description("Arcane framework CLI \u2014 scaffold and mana
7111
7703
  await printWelcome(pkg.version, process.cwd());
7112
7704
  program.outputHelp();
7113
7705
  });
7114
- 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(
7115
7709
  async (opts) => {
7116
7710
  await runInit(
7117
7711
  {
7118
7712
  profile: opts.profile,
7119
7713
  force: opts.force,
7120
7714
  dryRun: opts.dryRun,
7121
- user: opts.user
7715
+ user: opts.user,
7716
+ manifestFlags: manifestFlagsFrom(opts)
7122
7717
  },
7123
7718
  targetDirFor(opts),
7124
7719
  ASSETS_DIR,
@@ -7137,12 +7732,14 @@ program.command("add <component>").description("Add a component to an existing A
7137
7732
  );
7138
7733
  }
7139
7734
  );
7140
- program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
7141
- "--prune",
7142
- "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"
7143
- ).option("--user", USER_FLAG_DESCRIPTION).option(
7144
- "--default-scope <scope>",
7145
- "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
+ )
7146
7743
  ).action(async (opts) => {
7147
7744
  if (opts.defaultScope !== void 0) {
7148
7745
  if (!opts.user) {
@@ -7163,6 +7760,7 @@ program.command("update").description("Update installed components to the curren
7163
7760
  dryRun: opts.dryRun,
7164
7761
  prune: opts.prune,
7165
7762
  user: opts.user,
7763
+ manifestFlags: manifestFlagsFrom(opts),
7166
7764
  ...opts.defaultScope === void 0 ? {} : { defaultScope: opts.defaultScope }
7167
7765
  },
7168
7766
  targetDirFor(opts),
@@ -7176,6 +7774,9 @@ program.command("status").description("Show installed components and check for u
7176
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) => {
7177
7775
  await runUninstall({ yes: opts.yes, dryRun: opts.dryRun, user: opts.user }, targetDirFor(opts));
7178
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
+ });
7179
7780
  program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
7180
7781
  await runUnblockPush(process.cwd());
7181
7782
  });