arcane-cli 1.5.1 → 1.7.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.
Files changed (25) hide show
  1. package/README.md +37 -0
  2. package/dist/assets/.arcane/governance/agent-approved-paths.md +1 -1
  3. package/dist/assets/.arcane/governance/decision-documentation-standard.md +37 -1
  4. package/dist/assets/.arcane/governance/development-methodology.md +20 -0
  5. package/dist/assets/.arcane/governance/git-conventions.md +25 -3
  6. package/dist/assets/.arcane/governance/naming-conventions.md +1 -1
  7. package/dist/assets/.arcane/governance/spell-authoring-standards.md +40 -0
  8. package/dist/assets/.arcane/governance/universal-agent-rules.md +1 -1
  9. package/dist/assets/.arcane/spells/_fragments/push-policy-check.md +7 -0
  10. package/dist/assets/.arcane/spells/spell-address-review.md +15 -3
  11. package/dist/assets/.arcane/spells/spell-arcane-version.md +26 -0
  12. package/dist/assets/.arcane/spells/spell-check-drift.md +1 -0
  13. package/dist/assets/.arcane/spells/spell-close-session.md +45 -1
  14. package/dist/assets/.arcane/spells/spell-commit-work.md +23 -5
  15. package/dist/assets/.arcane/spells/spell-create-pull-request.md +19 -4
  16. package/dist/assets/.arcane/spells/spell-feedback.md +9 -0
  17. package/dist/assets/.arcane/spells/spell-manifest.md +25 -3
  18. package/dist/assets/.arcane/spells/spell-open-session.md +5 -0
  19. package/dist/assets/.arcane/spells/spell-save-idea.md +7 -1
  20. package/dist/assets/.arcane/spells/spell-ship.md +13 -1
  21. package/dist/assets/.arcane/spells/spell-sync-pull-request.md +12 -0
  22. package/dist/assets/.arcane/spells/spell-todo.md +84 -2
  23. package/dist/index.js +1170 -382
  24. package/dist/index.js.map +1 -1
  25. package/package.json +1 -1
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 join20 } 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) {
@@ -308,7 +322,8 @@ var PROFILE_CONFIGS = [
308
322
  "git-conventions",
309
323
  "testing-standards",
310
324
  "framework-decisions",
311
- "session-continuity"
325
+ "session-continuity",
326
+ "line-ending-baseline"
312
327
  ]
313
328
  },
314
329
  {
@@ -332,7 +347,8 @@ var PROFILE_CONFIGS = [
332
347
  "decision-documentation-standard",
333
348
  "agent-work-queue-model",
334
349
  "development-methodology",
335
- "spell-authoring-standards"
350
+ "spell-authoring-standards",
351
+ "line-ending-baseline"
336
352
  ]
337
353
  },
338
354
  {
@@ -357,6 +373,7 @@ var PROFILE_CONFIGS = [
357
373
  "spells-meta",
358
374
  "spells-docs",
359
375
  "docs-baseline",
376
+ "line-ending-baseline",
360
377
  "records-conventions",
361
378
  "agent-output-instructions",
362
379
  "git-conventions",
@@ -403,7 +420,8 @@ var PROFILE_CONFIGS = [
403
420
  "web-discoverability-standards",
404
421
  "mobile-release-standards",
405
422
  "records-conventions",
406
- "compliance-standards"
423
+ "compliance-standards",
424
+ "line-ending-baseline"
407
425
  ]
408
426
  }
409
427
  ];
@@ -532,15 +550,26 @@ var COMPONENTS = [
532
550
  // non-dotfile path and mapped via sourceOverrides; see RegistryComponent.
533
551
  {
534
552
  name: "docs-baseline",
535
- description: "Line-ending and binary-format baseline for document repositories (.gitattributes/.gitignore)",
536
- files: [".gitattributes", ".gitignore"],
553
+ description: "Ignore baseline for document repositories (.gitignore)",
554
+ files: [".gitignore"],
537
555
  sourceOverrides: {
538
- ".gitattributes": "docs-baseline/gitattributes",
539
556
  ".gitignore": "docs-baseline/gitignore"
540
557
  },
541
558
  skipExisting: true,
542
559
  initOnly: true
543
560
  },
561
+ // Split out of docs-baseline so every profile ships it (#281, PRD D-09);
562
+ // same asset source. See COMPONENT_FILE_MOVES for manifests written before.
563
+ {
564
+ name: "line-ending-baseline",
565
+ description: "Line-ending and binary-format baseline (.gitattributes): text normalizes to LF, binary formats are never rewritten",
566
+ files: [".gitattributes"],
567
+ sourceOverrides: {
568
+ ".gitattributes": "docs-baseline/gitattributes"
569
+ },
570
+ skipExisting: true,
571
+ initOnly: true
572
+ },
544
573
  // Optional MCP config scaffold (BC-22/I12). skipExisting: a repo's own
545
574
  // .mcp.json is user-owned server config Arcane must never overwrite.
546
575
  // Not initOnly -- useful to retrofit via `spell add` on an existing repo too.
@@ -768,6 +797,14 @@ var COMPONENTS = [
768
797
  ".github/prompts/spell-compliance.prompt.md",
769
798
  ".claude/commands/spell-compliance.md",
770
799
  ".agents/skills/spell-compliance/SKILL.md"
800
+ ],
801
+ // spell-make-discoverable cites the first two for every EV-/WD- rationale;
802
+ // spell-eas-store-deploy and spell-compliance consume the last two.
803
+ requires: [
804
+ "external-verification-standards",
805
+ "web-discoverability-standards",
806
+ "mobile-release-standards",
807
+ "compliance-standards"
771
808
  ]
772
809
  },
773
810
  {
@@ -872,6 +909,41 @@ var LEGACY_COMPONENT_MIGRATIONS = Object.assign(/* @__PURE__ */ Object.create(nu
872
909
  "spell-prompts": LEGACY_MONOLITH_REPLACEMENTS,
873
910
  "claude-commands": LEGACY_MONOLITH_REPLACEMENTS
874
911
  });
912
+ var REGISTRY_RETIREMENTS = [
913
+ {
914
+ name: "spell-bootstrap-business",
915
+ kind: "spell",
916
+ successors: ["spell-summon-venture"],
917
+ reason: "renamed in 0.16.0 with no alias (ARC-008 precedent) \u2014 it became a hub-gated venture creator with per-venture books and a registry entry"
918
+ },
919
+ {
920
+ name: "spell-assess",
921
+ kind: "spell",
922
+ successors: ["spell-scope"],
923
+ reason: "renamed to name its outcome, epic scoping and sequencing (ARC-007); no alias (ARC-008)"
924
+ },
925
+ {
926
+ name: "agent-files",
927
+ kind: "component",
928
+ successors: [],
929
+ reason: "`.github/agents/*.agent.md` files are rendered per roster by `spell agents init` / `spell agents sync` (ARC-047), which a fixed-path component cannot do"
930
+ },
931
+ {
932
+ name: "spell-prompts",
933
+ kind: "component",
934
+ successors: LEGACY_MONOLITH_REPLACEMENTS,
935
+ reason: "split in 0.18.0 into capability-scoped spells-* components; `spell update` migrates the entry itself"
936
+ },
937
+ {
938
+ name: "claude-commands",
939
+ kind: "component",
940
+ successors: LEGACY_MONOLITH_REPLACEMENTS,
941
+ reason: "split in 0.18.0 into capability-scoped spells-* components; `spell update` migrates the entry itself"
942
+ }
943
+ ];
944
+ var COMPONENT_FILE_MOVES = [
945
+ { from: "docs-baseline", to: "line-ending-baseline", files: [".gitattributes"] }
946
+ ];
875
947
  function getComponent(name) {
876
948
  const component = componentIndex.get(name);
877
949
  if (!component) {
@@ -2646,10 +2718,10 @@ async function runAgentsList(targetDir, scope = "repo") {
2646
2718
  console.log();
2647
2719
  }
2648
2720
 
2649
- // src/modules/push-safety.ts
2650
- import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm4, readdir as readdir5 } from "fs/promises";
2651
- import { constants } from "fs";
2652
- import { join as join5, resolve as resolve2, dirname as dirname3, basename } from "path";
2721
+ // src/modules/hub.ts
2722
+ import { confirm, select as select2, input as input2 } from "@inquirer/prompts";
2723
+ import { readdir as readdir5, writeFile as writeFile7 } from "fs/promises";
2724
+ import { join as join5 } from "path";
2653
2725
 
2654
2726
  // src/modules/git.ts
2655
2727
  import { execFile } from "child_process";
@@ -2869,7 +2941,371 @@ async function ensureLocalPullRebase(cwd) {
2869
2941
  }
2870
2942
  }
2871
2943
 
2944
+ // src/modules/ado-context.ts
2945
+ function remoteUrlHost(url) {
2946
+ const trimmed = url.trim();
2947
+ if (trimmed.includes("://")) {
2948
+ try {
2949
+ return new URL(trimmed).hostname.toLowerCase() || void 0;
2950
+ } catch {
2951
+ return void 0;
2952
+ }
2953
+ }
2954
+ const scp = /^(?:[^@/\s]+@)?([^:/\s]+):/.exec(trimmed);
2955
+ if (!scp || /^[a-zA-Z]$/.test(scp[1])) return void 0;
2956
+ return scp[1].toLowerCase();
2957
+ }
2958
+ function isAdoRemoteUrl(url) {
2959
+ const host = remoteUrlHost(url);
2960
+ if (host === void 0) return false;
2961
+ return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
2962
+ }
2963
+ async function detectAdoContext(targetDir) {
2964
+ let stdout;
2965
+ try {
2966
+ ({ stdout } = await runGit(targetDir, ["config", "--get-regexp", "^remote\\..*\\.url$"]));
2967
+ } catch {
2968
+ return false;
2969
+ }
2970
+ return stdout.split("\n").map((line) => line.slice(line.indexOf(" ") + 1)).some((url) => url.trim() !== "" && isAdoRemoteUrl(url));
2971
+ }
2972
+
2973
+ // src/modules/hub.ts
2974
+ async function askTrackingQuestions(targetDir) {
2975
+ const ado = targetDir !== void 0 && await detectAdoContext(targetDir);
2976
+ const tracking_mode = await select2({
2977
+ message: "How will work be tracked in this repo?",
2978
+ choices: [
2979
+ {
2980
+ value: "internal",
2981
+ name: "Track work in this repo (TODO.md / PRDs)",
2982
+ description: "Spells keep TODOs, PRDs and stories in this repository's own files."
2983
+ },
2984
+ {
2985
+ value: "external",
2986
+ name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)",
2987
+ description: "Spells tie PRDs, commits and PRs to items in the tracker you pick next."
2988
+ }
2989
+ ],
2990
+ ...ado ? { default: "external" } : {}
2991
+ });
2992
+ if (tracking_mode !== "external") return { tracking_mode, external_provider: null };
2993
+ const external_provider = await select2({
2994
+ message: "Which external tracker?",
2995
+ choices: [
2996
+ {
2997
+ value: "ado",
2998
+ name: "Azure DevOps",
2999
+ description: "Each PRD needs an ADO work item ID; plans follow your process template's item types."
3000
+ },
3001
+ {
3002
+ value: "github",
3003
+ name: "GitHub Issues",
3004
+ description: "Uses the gh CLI; an issue number is optional and linked from commits and PRs when known."
3005
+ },
3006
+ {
3007
+ value: "jira",
3008
+ name: "Jira",
3009
+ description: "No Jira automation yet: spells note linking as a TODO and keep planning in this repo."
3010
+ },
3011
+ {
3012
+ value: "other",
3013
+ name: "Other",
3014
+ description: "No automation: spells note linking as a TODO and keep planning in this repo."
3015
+ }
3016
+ ],
3017
+ ...ado ? { default: "ado" } : {}
3018
+ });
3019
+ return { tracking_mode, external_provider };
3020
+ }
3021
+ var MANIFEST_RETROFITS = [
3022
+ {
3023
+ field: "role",
3024
+ flag: "--role <hub|consumer>",
3025
+ needsRetrofit: (m) => m.role === void 0,
3026
+ ask: async () => {
3027
+ const isHub = await confirm({
3028
+ message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
3029
+ default: false
3030
+ });
3031
+ return { role: isHub ? "hub" : "consumer" };
3032
+ }
3033
+ },
3034
+ {
3035
+ field: "tracking_mode",
3036
+ flag: "--tracking-mode <internal|external>",
3037
+ needsRetrofit: (m) => m.tracking_mode === void 0,
3038
+ // Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
3039
+ // profiles get a silent default, full/lite get asked -- through the same
3040
+ // askTrackingQuestions init uses, so the two cannot drift.
3041
+ ask: async (manifest, targetDir) => {
3042
+ if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
3043
+ return { tracking_mode: "internal", external_provider: null };
3044
+ }
3045
+ return askTrackingQuestions(targetDir);
3046
+ }
3047
+ },
3048
+ {
3049
+ field: "content_sensitivity",
3050
+ flag: "--content-sensitivity <standard|sensitive>",
3051
+ needsRetrofit: (m) => m.content_sensitivity === void 0,
3052
+ // EF-12. Asked for every profile -- a code repo can hold sensitive records
3053
+ // too -- and defaults to "standard", so an operator who just presses enter
3054
+ // keeps today's behaviour exactly.
3055
+ ask: async () => {
3056
+ const content_sensitivity = await select2({
3057
+ message: "How should agents treat this repository's contents?",
3058
+ choices: [
3059
+ {
3060
+ value: "standard",
3061
+ name: "Standard \u2014 agents may quote contents in journals and decisions"
3062
+ },
3063
+ {
3064
+ value: "sensitive",
3065
+ name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
3066
+ }
3067
+ ],
3068
+ default: "standard"
3069
+ });
3070
+ return { content_sensitivity };
3071
+ }
3072
+ },
3073
+ {
3074
+ field: "subject_root",
3075
+ flag: "--subject-root <path>",
3076
+ // EF-07. Only the docs profile is asked: other profiles describe code or a
3077
+ // venture portfolio, where "what is this repo about" is already answered.
3078
+ // A docs install that legitimately holds several subjects answers
3079
+ // "portfolio" and stays unset -- so this retrofit deliberately does NOT
3080
+ // re-ask on every update. It is gated on the field being absent AND the
3081
+ // profile being docs, and once answered (either way) it never fires again,
3082
+ // because "portfolio" writes an explicit empty marker.
3083
+ needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
3084
+ ask: async () => {
3085
+ const shape = await select2({
3086
+ message: "What does this repository hold?",
3087
+ choices: [
3088
+ {
3089
+ value: "root",
3090
+ name: "One subject, at the repository root (documents sit alongside Arcane's files)"
3091
+ },
3092
+ { value: "subdir", name: "One subject, in its own directory" },
3093
+ { value: "portfolio", name: "Several subjects or ventures" }
3094
+ ]
3095
+ });
3096
+ if (shape === "root") return { subject_root: "." };
3097
+ if (shape === "subdir") {
3098
+ const answer = await input2({
3099
+ message: "Directory holding the subject's documents:",
3100
+ default: "docs",
3101
+ validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
3102
+ });
3103
+ return { subject_root: answer.trim() || "docs" };
3104
+ }
3105
+ return { subject_root: null };
3106
+ }
3107
+ },
3108
+ {
3109
+ field: "push_policy",
3110
+ flag: "--push-policy <open|guarded|blocked>",
3111
+ needsRetrofit: (m) => m.push_policy === void 0,
3112
+ // EF-09. Defaults to "open", so an operator who just presses enter keeps
3113
+ // today's behaviour exactly. NOTE: this only records the choice -- unlike
3114
+ // init, the retrofit does not install the hook or disable the push URL,
3115
+ // because an update should not silently start blocking pushes in a repo
3116
+ // someone is mid-workflow in (ARC-034 decision 7). ARC-049 decision 2:
3117
+ // update prints pushPolicyNotice() instead, naming `spell block-push`.
3118
+ ask: async () => {
3119
+ const push_policy = await select2({
3120
+ message: "Should this repository be allowed to push to a remote?",
3121
+ choices: [
3122
+ { value: "open", name: "Yes \u2014 normal repository" },
3123
+ { value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
3124
+ { value: "blocked", name: "No \u2014 block pushes" }
3125
+ ],
3126
+ default: "open"
3127
+ });
3128
+ return { push_policy };
3129
+ }
3130
+ }
3131
+ ];
3132
+ function pushPolicyNotice(policy) {
3133
+ if (policy === "blocked") {
3134
+ return [
3135
+ "",
3136
+ ' ! Required action: push_policy is now recorded as "blocked", but it is NOT enforced yet.',
3137
+ " `spell update` never installs push controls. Pushes from this repository still work",
3138
+ " until you run:",
3139
+ "",
3140
+ " spell block-push",
3141
+ ""
3142
+ ];
3143
+ }
3144
+ if (policy === "guarded") {
3145
+ return [
3146
+ "",
3147
+ ' push_policy is now recorded as "guarded". Nothing is installed for it: `spell doctor`',
3148
+ " reports it, and push-performing spells state the policy and ask before pushing.",
3149
+ ""
3150
+ ];
3151
+ }
3152
+ return [];
3153
+ }
3154
+ async function runManifestRetrofits(manifest, targetDir) {
3155
+ const applicable = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
3156
+ if (applicable.length === 0) return {};
3157
+ console.log();
3158
+ console.log(
3159
+ `This install predates ${applicable.length} manifest field${applicable.length === 1 ? "" : "s"} -- a couple of quick questions:`
3160
+ );
3161
+ let patch = {};
3162
+ for (const retrofit of applicable) {
3163
+ const answer = await retrofit.ask({ ...manifest, ...patch }, targetDir);
3164
+ patch = { ...patch, ...answer };
3165
+ }
3166
+ return patch;
3167
+ }
3168
+ function manifestFlagLine(manifest) {
3169
+ const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
3170
+ if (pending.length === 0) return void 0;
3171
+ const lines = [`spell update ${pending.map((r) => r.flag).join(" ")}`];
3172
+ if (pending.some((r) => r.field === "tracking_mode")) {
3173
+ lines.push("(--tracking-mode external also needs --external-provider <ado|github|jira|other>)");
3174
+ }
3175
+ return lines;
3176
+ }
3177
+ var ManifestFlagError = class extends Error {
3178
+ constructor(message) {
3179
+ super(message);
3180
+ this.name = "ManifestFlagError";
3181
+ }
3182
+ };
3183
+ var FLAG_NAMES = {
3184
+ role: "--role",
3185
+ tracking_mode: "--tracking-mode",
3186
+ external_provider: "--external-provider",
3187
+ content_sensitivity: "--content-sensitivity",
3188
+ subject_root: "--subject-root",
3189
+ push_policy: "--push-policy"
3190
+ };
3191
+ var PUSH_POLICY_STRICTNESS = { open: 0, guarded: 1, blocked: 2 };
3192
+ function hasManifestFlags(flags) {
3193
+ return flags !== void 0 && Object.values(flags).some((v) => v !== void 0);
3194
+ }
3195
+ function resolveManifestFlags(flags, current, manifestPath2) {
3196
+ if (!hasManifestFlags(flags)) return {};
3197
+ const requested = {};
3198
+ if (flags.role !== void 0) requested.role = flags.role;
3199
+ if (flags.trackingMode !== void 0) requested.tracking_mode = flags.trackingMode;
3200
+ if (flags.externalProvider !== void 0) requested.external_provider = flags.externalProvider;
3201
+ if (flags.contentSensitivity !== void 0) requested.content_sensitivity = flags.contentSensitivity;
3202
+ if (flags.subjectRoot !== void 0) requested.subject_root = flags.subjectRoot;
3203
+ if (flags.pushPolicy !== void 0) requested.push_policy = flags.pushPolicy;
3204
+ const invalid = (error) => {
3205
+ const allowed = error.field === "role" ? ["hub", "consumer"] : MANIFEST_ENUM_VALUES[error.field];
3206
+ const hint = allowed ? ` Valid values: ${allowed.join(", ")}.` : "";
3207
+ return new ManifestFlagError(`Invalid ${FLAG_NAMES[error.field] ?? error.field}: ${error.message}${hint}`);
3208
+ };
3209
+ if (requested.role !== void 0 && requested.role !== "hub" && requested.role !== "consumer") {
3210
+ throw invalid(new ManifestInvalidFieldError(manifestPath2, "role", requested.role));
3211
+ }
3212
+ try {
3213
+ validateManifestFields(requested, manifestPath2);
3214
+ } catch (error) {
3215
+ if (error instanceof ManifestInvalidFieldError) throw invalid(error);
3216
+ throw error;
3217
+ }
3218
+ if (requested.external_provider !== void 0 && requested.tracking_mode !== "external") {
3219
+ throw new ManifestFlagError("--external-provider applies only together with --tracking-mode external.");
3220
+ }
3221
+ if (requested.tracking_mode === "external" && requested.external_provider === void 0) {
3222
+ throw new ManifestFlagError(
3223
+ "--tracking-mode external needs --external-provider <ado|github|jira|other> as well."
3224
+ );
3225
+ }
3226
+ if (requested.tracking_mode === "internal") requested.external_provider = null;
3227
+ const patch = {};
3228
+ for (const field of Object.keys(FLAG_NAMES)) {
3229
+ if (!(field in requested)) continue;
3230
+ const value = requested[field];
3231
+ const recorded = current[field];
3232
+ if (recorded === void 0) {
3233
+ patch[field] = value;
3234
+ continue;
3235
+ }
3236
+ if (field === "push_policy") {
3237
+ const from = PUSH_POLICY_STRICTNESS[recorded];
3238
+ const to = PUSH_POLICY_STRICTNESS[value];
3239
+ if (to < from) {
3240
+ throw new ManifestFlagError(
3241
+ `--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.`
3242
+ );
3243
+ }
3244
+ if (to > from) patch[field] = value;
3245
+ continue;
3246
+ }
3247
+ if (recorded === value) continue;
3248
+ const flag = field === "external_provider" && flags.externalProvider === void 0 ? "--tracking-mode" : FLAG_NAMES[field];
3249
+ throw new ManifestFlagError(
3250
+ `${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.`
3251
+ );
3252
+ }
3253
+ return patch;
3254
+ }
3255
+ function describeFlagPatch(patch) {
3256
+ return Object.entries(patch).filter(([field]) => field in FLAG_NAMES).map(([field, value]) => `${field}: ${JSON.stringify(value)}`);
3257
+ }
3258
+ async function offerRegistryScaffold(targetDir, businessRoot) {
3259
+ const registryPath = join5(targetDir, businessRoot, "registry.json");
3260
+ if (await fileExists(registryPath)) return;
3261
+ const businessRootPath = join5(targetDir, businessRoot);
3262
+ let ventureDirs;
3263
+ try {
3264
+ const entries = await readdir5(businessRootPath, { withFileTypes: true });
3265
+ ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
3266
+ } catch {
3267
+ return;
3268
+ }
3269
+ if (ventureDirs.length === 0) return;
3270
+ console.log();
3271
+ const scaffold = await confirm({
3272
+ message: `Found ${ventureDirs.length} folder${ventureDirs.length === 1 ? "" : "s"} under ${businessRoot}/ -- scaffold ${businessRoot}/registry.json from them?`,
3273
+ default: true
3274
+ });
3275
+ if (!scaffold) return;
3276
+ const ventures = {};
3277
+ for (const slug of ventureDirs) {
3278
+ const include = await confirm({ message: ` Include "${slug}"?`, default: true });
3279
+ if (!include) continue;
3280
+ ventures[slug] = {
3281
+ name: slug,
3282
+ aliases: [],
3283
+ status: "active",
3284
+ visibility: "private",
3285
+ ownership: "llc",
3286
+ tracking: "none",
3287
+ repos: []
3288
+ };
3289
+ }
3290
+ if (Object.keys(ventures).length === 0) {
3291
+ console.log(" No ventures selected -- registry not created.");
3292
+ return;
3293
+ }
3294
+ const registry = {
3295
+ _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.",
3296
+ updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
3297
+ ventures
3298
+ };
3299
+ await writeFile7(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3300
+ console.log(
3301
+ ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
3302
+ );
3303
+ }
3304
+
2872
3305
  // src/modules/push-safety.ts
3306
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile8, chmod, access as access3, rm as rm4, readdir as readdir6 } from "fs/promises";
3307
+ import { constants } from "fs";
3308
+ import { join as join6, resolve as resolve2, dirname as dirname3, basename } from "path";
2873
3309
  var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
2874
3310
  var NO_PUSH_URL_MARKER = (remote) => `remote.${remote}.arcaneHadNoPushUrl`;
2875
3311
  var ARCANE_HOOKS_DIR = ".arcane/hooks";
@@ -2960,10 +3396,10 @@ async function repositoryRoot(cwd) {
2960
3396
  return cwd;
2961
3397
  }
2962
3398
  async function hookFilePath(cwd, hookName = "pre-push") {
2963
- return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
3399
+ return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2964
3400
  }
2965
3401
  async function hooksPathValue(cwd) {
2966
- return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
3402
+ return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2967
3403
  }
2968
3404
  async function installHook(cwd, hookName, body) {
2969
3405
  const root = await repositoryRoot(cwd);
@@ -2982,12 +3418,12 @@ async function installHook(cwd, hookName, body) {
2982
3418
  return { status: "refused-default-hooks", hooks: displaced };
2983
3419
  }
2984
3420
  }
2985
- const hookPath = join5(root, ARCANE_HOOKS_DIR, hookName);
3421
+ const hookPath = join6(root, ARCANE_HOOKS_DIR, hookName);
2986
3422
  if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
2987
3423
  return { status: "already-ours" };
2988
3424
  }
2989
- await mkdir5(join5(root, ARCANE_HOOKS_DIR), { recursive: true });
2990
- await writeFile7(hookPath, body, "utf-8");
3425
+ await mkdir5(join6(root, ARCANE_HOOKS_DIR), { recursive: true });
3426
+ await writeFile8(hookPath, body, "utf-8");
2991
3427
  await chmod(hookPath, 493);
2992
3428
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2993
3429
  return { status: "installed", path: hookPath };
@@ -3031,12 +3467,12 @@ async function defaultHooks(cwd) {
3031
3467
  let hooksDir;
3032
3468
  try {
3033
3469
  const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
3034
- hooksDir = join5(resolve2(cwd, stdout.trim()), "hooks");
3470
+ hooksDir = join6(resolve2(cwd, stdout.trim()), "hooks");
3035
3471
  } catch {
3036
3472
  return [];
3037
3473
  }
3038
3474
  try {
3039
- return (await readdir5(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
3475
+ return (await readdir6(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
3040
3476
  } catch {
3041
3477
  return [];
3042
3478
  }
@@ -3067,10 +3503,10 @@ async function removeHook(cwd, hookName) {
3067
3503
  await rm4(await hookFilePath(cwd, hookName), { force: true });
3068
3504
  } catch {
3069
3505
  }
3070
- const hooksDir = join5(root, ARCANE_HOOKS_DIR);
3506
+ const hooksDir = join6(root, ARCANE_HOOKS_DIR);
3071
3507
  let remaining = [];
3072
3508
  try {
3073
- remaining = await readdir5(hooksDir);
3509
+ remaining = await readdir6(hooksDir);
3074
3510
  } catch {
3075
3511
  remaining = [];
3076
3512
  }
@@ -3290,10 +3726,64 @@ function describeError(error) {
3290
3726
  const message = error instanceof Error ? error.message : String(error);
3291
3727
  return message.split("\n")[0].trim();
3292
3728
  }
3293
-
3294
- // src/modules/secrets-scan.ts
3295
- var SECRETS_RULES = [
3296
- { label: "api-key", pattern: /API[_-]KEY\s*[:=]\s*\S+/i },
3729
+ function hookInForce(outcome) {
3730
+ return outcome.status === "installed" || outcome.status === "already-ours";
3731
+ }
3732
+ async function applyBlockedPushControls(cwd, options = {}) {
3733
+ const messages = [];
3734
+ const say = (level, text) => {
3735
+ const message = { level, text };
3736
+ messages.push(message);
3737
+ options.emit?.(message);
3738
+ };
3739
+ const hook = await installPrePushHook(cwd);
3740
+ if (hook.status === "refused-unreadable-config") {
3741
+ say(
3742
+ "warning",
3743
+ "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`."
3744
+ );
3745
+ } else if (hook.status === "refused-default-hooks") {
3746
+ say(
3747
+ "warning",
3748
+ `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.`
3749
+ );
3750
+ } else if (hook.status === "refused-foreign-hooks-path") {
3751
+ say(
3752
+ "warning",
3753
+ `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.`
3754
+ );
3755
+ } else {
3756
+ say("info", "Installed a pre-push hook that blocks pushes from this repository.");
3757
+ }
3758
+ if (options.stopOnHookRefusal === true && !hookInForce(hook)) {
3759
+ return { hook, urls: [], stoppedAtHook: true, messages };
3760
+ }
3761
+ const urls = await disablePushUrls(cwd);
3762
+ const unprotected = urls.filter((u) => u.status === "failed");
3763
+ if (unprotected.length > 0) {
3764
+ say(
3765
+ "warning",
3766
+ `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.`
3767
+ );
3768
+ }
3769
+ if (urls.length === 0) {
3770
+ say(
3771
+ "warning",
3772
+ "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."
3773
+ );
3774
+ } else {
3775
+ const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
3776
+ if (covered.length > 0) {
3777
+ say("info", `Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
3778
+ }
3779
+ }
3780
+ say("info", "Run `spell unblock-push` from a terminal to undo this.");
3781
+ return { hook, urls, stoppedAtHook: false, messages };
3782
+ }
3783
+
3784
+ // src/modules/secrets-scan.ts
3785
+ var SECRETS_RULES = [
3786
+ { label: "api-key", pattern: /API[_-]KEY\s*[:=]\s*\S+/i },
3297
3787
  // Case-sensitive and requiring an all-caps/underscore identifier (SECRET,
3298
3788
  // MY_SECRET, API_SECRET_KEY...) -- the real config/env-var convention for a
3299
3789
  // credential-holding key. Widening this repository-wide (ARC-037 decision 3)
@@ -3399,6 +3889,13 @@ function buildProfileDescription(components, profile) {
3399
3889
  }
3400
3890
  async function runInit(options, targetDir, assetsDir, packageVersion) {
3401
3891
  if (options.user) {
3892
+ if (hasManifestFlags(options.manifestFlags)) {
3893
+ console.error(
3894
+ "\n \u2717 The manifest-question flags describe a repository; the user tier has none of those fields.\n"
3895
+ );
3896
+ process.exit(1);
3897
+ return;
3898
+ }
3402
3899
  await runInitUser(options, targetDir, assetsDir, packageVersion);
3403
3900
  return;
3404
3901
  }
@@ -3407,12 +3904,26 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3407
3904
  `Invalid profile "${options.profile}". Valid options: ${VALID_PROFILES.join(", ")}`
3408
3905
  );
3409
3906
  }
3907
+ let flags = {};
3908
+ try {
3909
+ flags = resolveManifestFlags(options.manifestFlags, {}, join7(targetDir, ".arcane.json"));
3910
+ } catch (err) {
3911
+ if (!(err instanceof ManifestFlagError)) throw err;
3912
+ console.error(`
3913
+ \u2717 ${err.message}
3914
+ `);
3915
+ process.exit(1);
3916
+ return;
3917
+ }
3410
3918
  try {
3411
3919
  await readManifest(targetDir);
3412
3920
  console.log(chalk2.hex("#a855f7")(`
3413
3921
  \u2726 Arcane v${packageVersion}
3414
3922
  `));
3415
3923
  console.log('Already initialized. Run "spell update" to update existing files.');
3924
+ if (hasManifestFlags(options.manifestFlags)) {
3925
+ console.log("Pass the same manifest-question flags to `spell update` to record them here.");
3926
+ }
3416
3927
  return;
3417
3928
  } catch (err) {
3418
3929
  if (!(err instanceof ManifestNotFoundError)) throw err;
@@ -3421,8 +3932,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3421
3932
  \u2726 Arcane v${packageVersion}
3422
3933
  `));
3423
3934
  let repoNotFound = false;
3935
+ let repoHasCommits = false;
3424
3936
  if (!options.dryRun) {
3425
3937
  const gitState = await inspectGitRepository(targetDir);
3938
+ repoHasCommits = gitState.status !== "not-repository" && gitState.status !== "no-commits";
3426
3939
  if (gitState.status === "not-repository") {
3427
3940
  repoNotFound = true;
3428
3941
  } else {
@@ -3444,7 +3957,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3444
3957
  );
3445
3958
  printInfo("Running spell init will add new files to your working tree.");
3446
3959
  console.log();
3447
- const continueAnyway = await confirm({
3960
+ const continueAnyway = await confirm2({
3448
3961
  message: "Continue anyway?",
3449
3962
  default: true
3450
3963
  });
@@ -3465,7 +3978,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3465
3978
  let profile = options.profile;
3466
3979
  if (!profile) {
3467
3980
  const profiles = listProfiles();
3468
- profile = await select2({
3981
+ profile = await select3({
3469
3982
  message: "Select an installation profile:\n",
3470
3983
  choices: profiles.map((p) => {
3471
3984
  const profileComponents2 = getProfile(p.id);
@@ -3490,7 +4003,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3490
4003
  }
3491
4004
  if (storeVersion !== void 0) {
3492
4005
  console.log();
3493
- const useTier = await confirm({
4006
+ const useTier = await confirm2({
3494
4007
  message: `This machine has an Arcane user tier (v${storeVersion}). Use it for this repository's spells instead of installing copies here?`,
3495
4008
  default: preferred === "user"
3496
4009
  });
@@ -3513,7 +4026,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3513
4026
  console.log(` ${previewParts.join(" \xB7 ")}`);
3514
4027
  console.log();
3515
4028
  if (!options.dryRun && !options.profile) {
3516
- const proceed = await confirm({
4029
+ const proceed = await confirm2({
3517
4030
  message: "Proceed with installation?",
3518
4031
  default: true
3519
4032
  });
@@ -3528,7 +4041,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3528
4041
  const installedFiles = [];
3529
4042
  const fileHashes = {};
3530
4043
  for (const file of component.files) {
3531
- const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
4044
+ const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
3532
4045
  if (options.dryRun) {
3533
4046
  printDryRun(`Would copy: ${file}`);
3534
4047
  } else {
@@ -3545,7 +4058,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3545
4058
  fileCount++;
3546
4059
  }
3547
4060
  for (const dir of component.directories ?? []) {
3548
- const srcDirPath = join6(assetsDir, dir);
4061
+ const srcDirPath = join7(assetsDir, dir);
3549
4062
  if (options.dryRun) {
3550
4063
  printDryRun(`Would copy directory: ${dir}/`);
3551
4064
  } else {
@@ -3574,7 +4087,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3574
4087
  if (options.dryRun) {
3575
4088
  if (hasAgents) {
3576
4089
  const agentFiles = await import("fs").then(
3577
- (fsModule) => fsModule.promises.readdir(join6(assetsDir, "agents"))
4090
+ (fsModule) => fsModule.promises.readdir(join7(assetsDir, "agents"))
3578
4091
  );
3579
4092
  const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
3580
4093
  printDryRun("Would run agent setup (interactive):");
@@ -3586,10 +4099,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3586
4099
  printDryRun(`Would initialize with profile "${profile}" \u2014 ${fileCount} files`);
3587
4100
  return;
3588
4101
  }
3589
- let role;
3590
- if (!options.profile) {
4102
+ let role = flags.role;
4103
+ if (role === void 0 && !options.profile) {
3591
4104
  console.log();
3592
- const isHub = await confirm({
4105
+ const isHub = await confirm2({
3593
4106
  message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
3594
4107
  default: false
3595
4108
  });
@@ -3597,36 +4110,20 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3597
4110
  }
3598
4111
  let tracking_mode;
3599
4112
  let external_provider;
3600
- if (profile === "governance-only" || profile === "methodology" || profile === "docs") {
4113
+ if (flags.tracking_mode !== void 0) {
4114
+ tracking_mode = flags.tracking_mode;
4115
+ external_provider = flags.external_provider ?? null;
4116
+ } else if (profile === "governance-only" || profile === "methodology" || profile === "docs") {
3601
4117
  tracking_mode = "internal";
3602
4118
  external_provider = null;
3603
4119
  } else if (!options.profile) {
3604
4120
  console.log();
3605
- tracking_mode = await select2({
3606
- message: "How will work be tracked in this repo?",
3607
- choices: [
3608
- { value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
3609
- { value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
3610
- ]
3611
- });
3612
- if (tracking_mode === "external") {
3613
- external_provider = await select2({
3614
- message: "Which external tracker?",
3615
- choices: [
3616
- { value: "ado", name: "Azure DevOps" },
3617
- { value: "github", name: "GitHub Issues" },
3618
- { value: "jira", name: "Jira" },
3619
- { value: "other", name: "Other" }
3620
- ]
3621
- });
3622
- } else {
3623
- external_provider = null;
3624
- }
4121
+ ({ tracking_mode, external_provider } = await askTrackingQuestions(targetDir));
3625
4122
  }
3626
- let subject_root;
3627
- if (profile === "docs" && !options.profile) {
4123
+ let subject_root = flags.subject_root;
4124
+ if (subject_root === void 0 && profile === "docs" && !options.profile) {
3628
4125
  console.log();
3629
- const shape = await select2({
4126
+ const shape = await select3({
3630
4127
  message: "What does this repository hold?",
3631
4128
  choices: [
3632
4129
  {
@@ -3640,7 +4137,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3640
4137
  if (shape === "root") {
3641
4138
  subject_root = ".";
3642
4139
  } else if (shape === "subdir") {
3643
- const answer = await input2({
4140
+ const answer = await input3({
3644
4141
  message: "Directory holding the subject's documents:",
3645
4142
  default: "docs",
3646
4143
  validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
@@ -3650,10 +4147,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3650
4147
  subject_root = null;
3651
4148
  }
3652
4149
  }
3653
- let content_sensitivity;
3654
- if (!options.profile) {
4150
+ let content_sensitivity = flags.content_sensitivity;
4151
+ if (content_sensitivity === void 0 && !options.profile) {
3655
4152
  console.log();
3656
- content_sensitivity = await select2({
4153
+ content_sensitivity = await select3({
3657
4154
  message: "How should agents treat this repository's contents?",
3658
4155
  choices: [
3659
4156
  { value: "standard", name: "Standard \u2014 agents may quote contents in journals and decisions" },
@@ -3665,10 +4162,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3665
4162
  default: "standard"
3666
4163
  });
3667
4164
  }
3668
- let push_policy;
3669
- if (!options.profile) {
4165
+ let push_policy = flags.push_policy;
4166
+ if (push_policy === void 0 && !options.profile) {
3670
4167
  console.log();
3671
- push_policy = await select2({
4168
+ push_policy = await select3({
3672
4169
  message: "Should this repository be allowed to push to a remote?",
3673
4170
  choices: [
3674
4171
  { value: "open", name: "Yes \u2014 normal repository" },
@@ -3695,43 +4192,23 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3695
4192
  };
3696
4193
  await writeManifest(targetDir, manifest);
3697
4194
  printSuccess(`Initialized with profile "${profile}" \u2014 ${fileCount} files installed`);
4195
+ const flagLine = options.profile ? manifestFlagLine(manifest) : void 0;
4196
+ if (flagLine) {
4197
+ printInfo("Some setup questions were left unanswered. Answer them without a prompt with:");
4198
+ for (const line of flagLine) printInfo(` ${line}`);
4199
+ }
3698
4200
  if (push_policy === "blocked" || push_policy === "guarded") {
3699
4201
  try {
3700
4202
  if (push_policy === "blocked") {
3701
- const hook = await installPrePushHook(targetDir);
3702
- if (hook.status === "refused-unreadable-config") {
3703
- printWarning(
3704
- "Did not install the pre-push hook: git could not report the current core.hooksPath (unreadable config, or a git too old for `--show-scope`). Installing without knowing whether another hook manager owns that setting could silently disable it. Fix the config and run `spell doctor`."
3705
- );
3706
- } else if (hook.status === "refused-default-hooks") {
3707
- printWarning(
3708
- `Did not install the pre-push hook: this repository has hooks in git's default directory (${hook.hooks.join(", ")}). Setting core.hooksPath would silently stop them running. Move them under \`${ARCANE_HOOKS_DIR}\` yourself and re-run, or add the push guard to your existing pre-push hook.`
3709
- );
3710
- } else if (hook.status === "refused-foreign-hooks-path") {
3711
- printWarning(
3712
- `Did not install the pre-push hook: core.hooksPath is already "${hook.existing}" (${describeConfigScope(hook.scope)}), so another hook manager owns it. Overwriting would silently disable those hooks. Chain an Arcane pre-push guard into that directory yourself, or unset core.hooksPath first.`
3713
- );
3714
- } else {
3715
- printInfo("Installed a pre-push hook that blocks pushes from this repository.");
3716
- }
3717
- const urls = await disablePushUrls(targetDir);
3718
- const unprotected = urls.filter((u) => u.status === "failed");
3719
- if (unprotected.length > 0) {
4203
+ const controls = await applyBlockedPushControls(targetDir, {
4204
+ emit: (m) => m.level === "warning" ? printWarning(m.text) : printInfo(m.text)
4205
+ });
4206
+ if (!hookInForce(controls.hook) || controls.urls.some((u) => u.status === "failed")) {
3720
4207
  printWarning(
3721
- `Could not disable the push URL for: ${unprotected.map((u) => `${u.remote} (${u.reason ?? "unknown error"})`).join("; ")}. Those remotes are still pushable with \`--no-verify\`. Run \`spell doctor\` \u2014 it lists exactly which remotes are still live.`
4208
+ '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.'
3722
4209
  );
4210
+ process.exitCode = 1;
3723
4211
  }
3724
- if (urls.length === 0) {
3725
- printWarning(
3726
- "No remote is configured, so only the pre-push hook is active. A remote added later will NOT have its push URL disabled automatically, and a `--no-verify` push to it would succeed. Run `spell doctor` after adding one \u2014 it reports this gap."
3727
- );
3728
- } else {
3729
- const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
3730
- if (covered.length > 0) {
3731
- printInfo(`Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
3732
- }
3733
- }
3734
- printInfo("Run `spell unblock-push` from a terminal to undo this.");
3735
4212
  } else {
3736
4213
  printInfo(
3737
4214
  "Marked as push-guarded. No technical control was installed \u2014 check the remote is the one you intend before pushing. `spell doctor` will keep reminding you."
@@ -3777,7 +4254,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3777
4254
  let agentsConfigured = false;
3778
4255
  if (hasAgents && !options.profile) {
3779
4256
  console.log();
3780
- const setupAgents = await confirm({
4257
+ const setupAgents = await confirm2({
3781
4258
  message: "\u{1F916} Set up your agent team? (names, roles, AI tool configs)",
3782
4259
  default: true
3783
4260
  });
@@ -3811,9 +4288,15 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3811
4288
  'Initialize Git for this project: `git init -b main` (avoids defaulting to "master" on systems where that\'s the system default)'
3812
4289
  );
3813
4290
  }
4291
+ const wroteGitattributes = installedComponents.some((c) => c.files.includes(".gitattributes"));
4292
+ if (wroteGitattributes && repoHasCommits) {
4293
+ printWarning(
4294
+ "Installed .gitattributes, which normalizes text to LF. In a repository with history, files committed with CRLF can show as modified once Git renormalizes them (for example after `git add --renormalize .`). Review and commit that as its own change."
4295
+ );
4296
+ }
3814
4297
  printNextStep(
3815
4298
  step++,
3816
- "Commit the new files: git add .arcane .github .claude AGENTS.md CLAUDE.md .arcane.json"
4299
+ `Commit the new files: git add .arcane .github .claude AGENTS.md CLAUDE.md .arcane.json${wroteGitattributes ? " .gitattributes" : ""}`
3817
4300
  );
3818
4301
  console.log();
3819
4302
  }
@@ -3823,6 +4306,11 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3823
4306
  '"--profile" does not apply to "--user": the user tier always installs every spell and nothing else.'
3824
4307
  );
3825
4308
  }
4309
+ if (hasManifestFlags(options.manifestFlags)) {
4310
+ throw new Error(
4311
+ 'The manifest-question flags do not apply to "--user": they describe a repository, and the user tier is not one.'
4312
+ );
4313
+ }
3826
4314
  try {
3827
4315
  await readManifest(storeRoot);
3828
4316
  console.log(chalk2.hex("#a855f7")(`
@@ -3856,7 +4344,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3856
4344
  for (const id of spellIds) {
3857
4345
  for (const client of ["codex", "claude"]) {
3858
4346
  const relativePath = USER_FANOUT_PATHS[client](id);
3859
- if (await fileExists(join6(homeDir, relativePath))) {
4347
+ if (await fileExists(join7(homeDir, relativePath))) {
3860
4348
  printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
3861
4349
  } else {
3862
4350
  printDryRun(`Would write: ~/${relativePath}`);
@@ -3874,7 +4362,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3874
4362
  const installedFiles = [];
3875
4363
  const fileHashes = {};
3876
4364
  for (const file of component.files) {
3877
- const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
4365
+ const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
3878
4366
  fileHashes[file] = await copyFile(srcPath, storeRoot, file, { force: options.force });
3879
4367
  installedFiles.push(file);
3880
4368
  fileCount++;
@@ -3920,7 +4408,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3920
4408
  }
3921
4409
 
3922
4410
  // src/commands/add.ts
3923
- import { join as join7 } from "path";
4411
+ import { join as join8 } from "path";
3924
4412
  async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3925
4413
  let manifest;
3926
4414
  try {
@@ -3956,7 +4444,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3956
4444
  const installedFiles = [];
3957
4445
  const fileHashes = {};
3958
4446
  for (const file of component.files) {
3959
- const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
4447
+ const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
3960
4448
  if (options.dryRun) {
3961
4449
  console.log(` [dry-run] Would copy: ${file}`);
3962
4450
  } else {
@@ -3966,7 +4454,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3966
4454
  fileCount++;
3967
4455
  }
3968
4456
  for (const dir of component.directories ?? []) {
3969
- const srcDirPath = join7(assetsDir, dir);
4457
+ const srcDirPath = join8(assetsDir, dir);
3970
4458
  if (options.dryRun) {
3971
4459
  console.log(` [dry-run] Would copy directory: ${dir}/`);
3972
4460
  } else {
@@ -4000,197 +4488,9 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
4000
4488
  }
4001
4489
 
4002
4490
  // src/commands/update.ts
4003
- import { dirname as dirname5, join as join9 } from "path";
4491
+ import { dirname as dirname5, join as join10 } from "path";
4004
4492
  import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
4005
4493
 
4006
- // src/modules/hub.ts
4007
- import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
4008
- import { readdir as readdir6, writeFile as writeFile8 } from "fs/promises";
4009
- import { join as join8 } from "path";
4010
- var MANIFEST_RETROFITS = [
4011
- {
4012
- field: "role",
4013
- needsRetrofit: (m) => m.role === void 0,
4014
- ask: async () => {
4015
- const isHub = await confirm2({
4016
- message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
4017
- default: false
4018
- });
4019
- return { role: isHub ? "hub" : "consumer" };
4020
- }
4021
- },
4022
- {
4023
- field: "tracking_mode",
4024
- needsRetrofit: (m) => m.tracking_mode === void 0,
4025
- // Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
4026
- // profiles get a silent default, full/lite get asked.
4027
- ask: async (manifest) => {
4028
- if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
4029
- return { tracking_mode: "internal", external_provider: null };
4030
- }
4031
- const tracking_mode = await select3({
4032
- message: "How will work be tracked in this repo?",
4033
- choices: [
4034
- { value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
4035
- { value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
4036
- ]
4037
- });
4038
- if (tracking_mode === "external") {
4039
- const external_provider = await select3({
4040
- message: "Which external tracker?",
4041
- choices: [
4042
- { value: "ado", name: "Azure DevOps" },
4043
- { value: "github", name: "GitHub Issues" },
4044
- { value: "jira", name: "Jira" },
4045
- { value: "other", name: "Other" }
4046
- ]
4047
- });
4048
- return { tracking_mode, external_provider };
4049
- }
4050
- return { tracking_mode, external_provider: null };
4051
- }
4052
- },
4053
- {
4054
- field: "content_sensitivity",
4055
- needsRetrofit: (m) => m.content_sensitivity === void 0,
4056
- // EF-12. Asked for every profile -- a code repo can hold sensitive records
4057
- // too -- and defaults to "standard", so an operator who just presses enter
4058
- // keeps today's behaviour exactly.
4059
- ask: async () => {
4060
- const content_sensitivity = await select3({
4061
- message: "How should agents treat this repository's contents?",
4062
- choices: [
4063
- {
4064
- value: "standard",
4065
- name: "Standard \u2014 agents may quote contents in journals and decisions"
4066
- },
4067
- {
4068
- value: "sensitive",
4069
- name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
4070
- }
4071
- ],
4072
- default: "standard"
4073
- });
4074
- return { content_sensitivity };
4075
- }
4076
- },
4077
- {
4078
- field: "subject_root",
4079
- // EF-07. Only the docs profile is asked: other profiles describe code or a
4080
- // venture portfolio, where "what is this repo about" is already answered.
4081
- // A docs install that legitimately holds several subjects answers
4082
- // "portfolio" and stays unset -- so this retrofit deliberately does NOT
4083
- // re-ask on every update. It is gated on the field being absent AND the
4084
- // profile being docs, and once answered (either way) it never fires again,
4085
- // because "portfolio" writes an explicit empty marker.
4086
- needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
4087
- ask: async () => {
4088
- const shape = await select3({
4089
- message: "What does this repository hold?",
4090
- choices: [
4091
- {
4092
- value: "root",
4093
- name: "One subject, at the repository root (documents sit alongside Arcane's files)"
4094
- },
4095
- { value: "subdir", name: "One subject, in its own directory" },
4096
- { value: "portfolio", name: "Several subjects or ventures" }
4097
- ]
4098
- });
4099
- if (shape === "root") return { subject_root: "." };
4100
- if (shape === "subdir") {
4101
- const answer = await input3({
4102
- message: "Directory holding the subject's documents:",
4103
- default: "docs",
4104
- validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
4105
- });
4106
- return { subject_root: answer.trim() || "docs" };
4107
- }
4108
- return { subject_root: null };
4109
- }
4110
- },
4111
- {
4112
- field: "push_policy",
4113
- needsRetrofit: (m) => m.push_policy === void 0,
4114
- // EF-09. Defaults to "open", so an operator who just presses enter keeps
4115
- // today's behaviour exactly. NOTE: this only records the choice -- unlike
4116
- // init, the retrofit does not install the hook or disable the push URL,
4117
- // because an update should not silently start blocking pushes in a repo
4118
- // someone is mid-workflow in. doctor reports the gap instead.
4119
- ask: async () => {
4120
- const push_policy = await select3({
4121
- message: "Should this repository be allowed to push to a remote?",
4122
- choices: [
4123
- { value: "open", name: "Yes \u2014 normal repository" },
4124
- { value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
4125
- { value: "blocked", name: "No \u2014 block pushes" }
4126
- ],
4127
- default: "open"
4128
- });
4129
- return { push_policy };
4130
- }
4131
- }
4132
- ];
4133
- async function runManifestRetrofits(manifest) {
4134
- const applicable = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
4135
- if (applicable.length === 0) return {};
4136
- console.log();
4137
- console.log(
4138
- `This install predates ${applicable.length} manifest field${applicable.length === 1 ? "" : "s"} -- a couple of quick questions:`
4139
- );
4140
- let patch = {};
4141
- for (const retrofit of applicable) {
4142
- const answer = await retrofit.ask({ ...manifest, ...patch });
4143
- patch = { ...patch, ...answer };
4144
- }
4145
- return patch;
4146
- }
4147
- async function offerRegistryScaffold(targetDir, businessRoot) {
4148
- const registryPath = join8(targetDir, businessRoot, "registry.json");
4149
- if (await fileExists(registryPath)) return;
4150
- const businessRootPath = join8(targetDir, businessRoot);
4151
- let ventureDirs;
4152
- try {
4153
- const entries = await readdir6(businessRootPath, { withFileTypes: true });
4154
- ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
4155
- } catch {
4156
- return;
4157
- }
4158
- if (ventureDirs.length === 0) return;
4159
- console.log();
4160
- const scaffold = await confirm2({
4161
- message: `Found ${ventureDirs.length} folder${ventureDirs.length === 1 ? "" : "s"} under ${businessRoot}/ -- scaffold ${businessRoot}/registry.json from them?`,
4162
- default: true
4163
- });
4164
- if (!scaffold) return;
4165
- const ventures = {};
4166
- for (const slug of ventureDirs) {
4167
- const include = await confirm2({ message: ` Include "${slug}"?`, default: true });
4168
- if (!include) continue;
4169
- ventures[slug] = {
4170
- name: slug,
4171
- aliases: [],
4172
- status: "active",
4173
- visibility: "private",
4174
- ownership: "llc",
4175
- tracking: "none",
4176
- repos: []
4177
- };
4178
- }
4179
- if (Object.keys(ventures).length === 0) {
4180
- console.log(" No ventures selected -- registry not created.");
4181
- return;
4182
- }
4183
- const registry = {
4184
- _comment: "Hub-owned venture registry. Private. Never installed or modified by spell update. Seeded by the retrofit wizard -- review and enrich (aliases, ownership, tracking, clones) before relying on it.",
4185
- updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
4186
- ventures
4187
- };
4188
- await writeFile8(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
4189
- console.log(
4190
- ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
4191
- );
4192
- }
4193
-
4194
4494
  // src/modules/merge3.ts
4195
4495
  var CONFLICT_START = "<<<<<<< yours (edited)";
4196
4496
  var CONFLICT_MID = "=======";
@@ -4319,6 +4619,101 @@ function merge3(base, ours, theirs) {
4319
4619
  return { content: resultLines.join("\n"), hasConflict };
4320
4620
  }
4321
4621
 
4622
+ // src/modules/registry-changes.ts
4623
+ import { join as join9 } from "path";
4624
+ function findMissingRequires(installed, profile) {
4625
+ const names = new Set(installed.flatMap((c) => LEGACY_COMPONENT_MIGRATIONS[c.name] ?? [c.name]));
4626
+ const promised = listProfiles().find((p) => p.id === profile)?.components;
4627
+ const missing = /* @__PURE__ */ new Map();
4628
+ for (const name of names) {
4629
+ let requires;
4630
+ try {
4631
+ requires = getComponent(name).requires ?? [];
4632
+ } catch (err) {
4633
+ if (err instanceof ComponentNotFoundError) continue;
4634
+ throw err;
4635
+ }
4636
+ for (const required of requires) {
4637
+ if (names.has(required)) continue;
4638
+ if (promised && !promised.includes(required)) continue;
4639
+ missing.set(required, [...missing.get(required) ?? [], name]);
4640
+ }
4641
+ }
4642
+ return [...missing].map(([name, requiredBy]) => ({ name, requiredBy }));
4643
+ }
4644
+ var SPELL_ID_IN_PATH = /(?:^|\/)(spell-[a-z0-9-]+)(?:\/SKILL\.md|\.prompt\.md|\.md)$/;
4645
+ function spellIdFromTrackedPath(file) {
4646
+ return SPELL_ID_IN_PATH.exec(file)?.[1];
4647
+ }
4648
+ function findTrackedRetirements(components) {
4649
+ const componentNames = new Set(components.map((c) => c.name));
4650
+ const spellIds = new Set(
4651
+ components.flatMap((c) => c.files).map(spellIdFromTrackedPath).filter((id) => id !== void 0)
4652
+ );
4653
+ return REGISTRY_RETIREMENTS.filter(
4654
+ (entry) => entry.kind === "component" ? componentNames.has(entry.name) : spellIds.has(entry.name)
4655
+ );
4656
+ }
4657
+ async function findNewlyAvailable(targetDir, profile, installed, spellScope) {
4658
+ const definition = listProfiles().find((p) => p.id === profile);
4659
+ if (!definition) return [];
4660
+ const installedNames = new Set(installed.map((c) => c.name));
4661
+ const found = [];
4662
+ for (const name of definition.components) {
4663
+ if (installedNames.has(name)) continue;
4664
+ if (spellScope === "user" && SPELL_COMPONENT_NAMES.includes(name)) continue;
4665
+ const component = getComponent(name);
4666
+ const alreadyPresent = [];
4667
+ if (component.skipExisting) {
4668
+ for (const file of component.files) {
4669
+ if (await fileExists(join9(targetDir, file))) alreadyPresent.push(file);
4670
+ }
4671
+ if (component.files.length > 0 && alreadyPresent.length === component.files.length) continue;
4672
+ }
4673
+ found.push({ name, description: component.description, alreadyPresent });
4674
+ }
4675
+ return found;
4676
+ }
4677
+ function formatRegistryChanges(changes) {
4678
+ const missingRequires = changes.missingRequires ?? [];
4679
+ const newlyAvailable = (changes.newlyAvailable ?? []).filter(
4680
+ (c) => !missingRequires.some((m) => m.name === c.name)
4681
+ );
4682
+ if (changes.retired.length === 0 && newlyAvailable.length === 0 && missingRequires.length === 0) return [];
4683
+ const lines = ["", "Registry changes since your install:"];
4684
+ if (changes.retired.length > 0) {
4685
+ lines.push(" Renamed or retired, still tracked here:");
4686
+ for (const entry of changes.retired) {
4687
+ const successor = entry.successors.length > 0 ? entry.successors.join(", ") : "no successor";
4688
+ lines.push(` ${entry.name} (${entry.kind}) \u2192 ${successor}: ${entry.reason}`);
4689
+ }
4690
+ lines.push(
4691
+ " Nothing is deleted because of this list: their files go through the orphan handling above, which removes an untouched one only under `spell update --prune`."
4692
+ );
4693
+ }
4694
+ if (newlyAvailable.length > 0) {
4695
+ const profile = changes.profile ? ` "${changes.profile}"` : "";
4696
+ lines.push(` Newly available for your${profile} profile, not installed \u2014 nothing was added:`);
4697
+ for (const component of newlyAvailable) {
4698
+ lines.push(` ${component.name} \u2014 ${component.description}`);
4699
+ lines.push(` spell add ${component.name}`);
4700
+ if (component.alreadyPresent.length > 0) {
4701
+ lines.push(
4702
+ ` (already here and yours: ${component.alreadyPresent.join(", ")} \u2014 \`spell add\` stops at an existing file rather than overwrite it)`
4703
+ );
4704
+ }
4705
+ }
4706
+ }
4707
+ if (missingRequires.length > 0) {
4708
+ lines.push(" Missing prerequisites \u2014 installed components cite these, and they are not installed:");
4709
+ for (const { name, requiredBy } of missingRequires) {
4710
+ lines.push(` ${name} (required by ${requiredBy.join(", ")})`);
4711
+ lines.push(` spell add ${name}`);
4712
+ }
4713
+ }
4714
+ return lines;
4715
+ }
4716
+
4322
4717
  // src/modules/npm-registry.ts
4323
4718
  var NPM_PACKAGE_NAME = "arcane-cli";
4324
4719
  async function fetchPublishedFile(version, relativeAssetPath) {
@@ -4354,6 +4749,14 @@ async function missingTierWarning(scope, spellScope, homeDir) {
4354
4749
  ];
4355
4750
  }
4356
4751
  }
4752
+ function reportFlagPatch(flagPatch, before, after, dryRun) {
4753
+ console.log(`
4754
+ ${dryRun ? "[dry-run] Would record" : "Recorded"} from flags:`);
4755
+ for (const line of describeFlagPatch(flagPatch)) console.log(` ${line}`);
4756
+ if (!dryRun && after.push_policy !== before.push_policy) {
4757
+ for (const line of pushPolicyNotice(after.push_policy)) console.log(line);
4758
+ }
4759
+ }
4357
4760
  function defaultScopePatch(options, manifest) {
4358
4761
  if (options.defaultScope === void 0) return void 0;
4359
4762
  if (manifest.default_spell_scope === options.defaultScope) return void 0;
@@ -4392,6 +4795,44 @@ function migrateLegacyComponents(components) {
4392
4795
  inherited: { files: inheritedFiles, fileHashes: inheritedHashes, carrier }
4393
4796
  };
4394
4797
  }
4798
+ function moveComponentFiles(components, present = () => true) {
4799
+ let out = components;
4800
+ for (const move of COMPONENT_FILE_MOVES) {
4801
+ const from = out.find((c) => c.name === move.from);
4802
+ const tracked = from?.files.filter((f) => move.files.includes(f)) ?? [];
4803
+ if (!from || tracked.length === 0) continue;
4804
+ const moving = tracked.filter(present);
4805
+ const dropped = tracked.filter((f) => !present(f));
4806
+ const movedHashes = {};
4807
+ const keptHashes = {};
4808
+ for (const [file, hash] of Object.entries(from.fileHashes ?? {})) {
4809
+ if (dropped.includes(file)) continue;
4810
+ (moving.includes(file) ? movedHashes : keptHashes)[file] = hash;
4811
+ }
4812
+ const newFrom = {
4813
+ ...from,
4814
+ files: from.files.filter((f) => !tracked.includes(f)),
4815
+ ...from.fileHashes ? { fileHashes: keptHashes } : {}
4816
+ };
4817
+ const existing = out.find((c) => c.name === move.to);
4818
+ const newTo = existing ? {
4819
+ ...existing,
4820
+ files: [...existing.files, ...moving.filter((f) => !existing.files.includes(f))],
4821
+ fileHashes: { ...movedHashes, ...existing.fileHashes }
4822
+ } : {
4823
+ name: move.to,
4824
+ files: moving,
4825
+ installedVersion: from.installedVersion,
4826
+ ...from.fileHashes ? { fileHashes: movedHashes } : {}
4827
+ };
4828
+ out = out.flatMap((c) => {
4829
+ if (c === from) return existing || moving.length === 0 ? [newFrom] : [newFrom, newTo];
4830
+ if (c === existing) return [newTo];
4831
+ return [c];
4832
+ });
4833
+ }
4834
+ return out;
4835
+ }
4395
4836
  async function resolveOrphan(targetDir, file, recordedHash, prune) {
4396
4837
  try {
4397
4838
  validateTargetPath(targetDir, file);
@@ -4399,7 +4840,7 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
4399
4840
  console.log(` ! Refused (path escapes the target directory): ${file}`);
4400
4841
  return "reported";
4401
4842
  }
4402
- const filePath = join9(targetDir, file);
4843
+ const filePath = join10(targetDir, file);
4403
4844
  if (!await fileExists(filePath)) return "not-found";
4404
4845
  if (!prune) return "reported";
4405
4846
  if (recordedHash === void 0) {
@@ -4421,7 +4862,7 @@ async function retainReportedOrphan(targetDir, file, recordedHash, into) {
4421
4862
  } catch {
4422
4863
  return;
4423
4864
  }
4424
- if (!await fileExists(join9(targetDir, file))) return;
4865
+ if (!await fileExists(join10(targetDir, file))) return;
4425
4866
  if (!into.files.includes(file)) into.files.push(file);
4426
4867
  if (recordedHash !== void 0) {
4427
4868
  into.fileHashes ??= {};
@@ -4450,7 +4891,7 @@ async function findMissingTrackedFiles(targetDir, components, scope = "repo", sp
4450
4891
  const candidates = readopted.has(installed.name) ? component.files : installed.files;
4451
4892
  for (const file of candidates) {
4452
4893
  if (!component.files.includes(file)) continue;
4453
- if (!await fileExists(join9(targetDir, file))) missing.push(file);
4894
+ if (!await fileExists(join10(targetDir, file))) missing.push(file);
4454
4895
  }
4455
4896
  }
4456
4897
  return missing;
@@ -4489,6 +4930,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4489
4930
  }
4490
4931
  throw err;
4491
4932
  }
4933
+ const movedFilesPresent = /* @__PURE__ */ new Set();
4934
+ for (const move of COMPONENT_FILE_MOVES) {
4935
+ for (const file of move.files) {
4936
+ if (await fileExists(join10(targetDir, file))) movedFilesPresent.add(file);
4937
+ }
4938
+ }
4939
+ manifest = {
4940
+ ...manifest,
4941
+ components: moveComponentFiles(manifest.components, (f) => movedFilesPresent.has(f))
4942
+ };
4492
4943
  const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4493
4944
  if (resolvedScope.misplacedUserManifest) {
4494
4945
  console.error(misplacedUserManifestMessage(targetDir));
@@ -4496,6 +4947,27 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4496
4947
  return;
4497
4948
  }
4498
4949
  const scope = resolvedScope.scope;
4950
+ let flagPatch = {};
4951
+ if (hasManifestFlags(options.manifestFlags)) {
4952
+ if (scope !== "repo") {
4953
+ console.error(
4954
+ "\n \u2717 The manifest-question flags describe a repository; the user tier has none of those fields.\n"
4955
+ );
4956
+ process.exit(1);
4957
+ return;
4958
+ }
4959
+ try {
4960
+ flagPatch = resolveManifestFlags(options.manifestFlags, manifest, join10(targetDir, ".arcane.json"));
4961
+ } catch (err) {
4962
+ if (!(err instanceof ManifestFlagError)) throw err;
4963
+ console.error(`
4964
+ \u2717 ${err.message}
4965
+ `);
4966
+ process.exit(1);
4967
+ return;
4968
+ }
4969
+ }
4970
+ const answered = { ...manifest, ...flagPatch };
4499
4971
  const spellScope = effectiveSpellScope(manifest);
4500
4972
  const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
4501
4973
  const homeDir = dirname5(targetDir);
@@ -4530,12 +5002,33 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4530
5002
  return;
4531
5003
  }
4532
5004
  }
5005
+ const registryChanges = {
5006
+ retired: findTrackedRetirements(manifest.components),
5007
+ ...scope === "repo" ? {
5008
+ profile: manifest.profile,
5009
+ newlyAvailable: await findNewlyAvailable(
5010
+ targetDir,
5011
+ manifest.profile,
5012
+ migrateLegacyComponents(manifest.components).components,
5013
+ spellScope
5014
+ ),
5015
+ missingRequires: findMissingRequires(manifest.components, manifest.profile)
5016
+ } : {}
5017
+ };
5018
+ const printRegistryChanges = () => {
5019
+ for (const line of formatRegistryChanges(registryChanges)) console.log(line);
5020
+ };
4533
5021
  let sameVersionRestore = false;
4534
5022
  if (manifest.version === packageVersion && manifest.components.length > 0) {
4535
5023
  const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope, spellScope, readopted);
4536
5024
  const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
4537
5025
  if (missing.length === 0 && unmanaged.length === 0) {
4538
5026
  console.log("Already up to date.");
5027
+ printRegistryChanges();
5028
+ if (scope === "repo" && Object.keys(flagPatch).length > 0) {
5029
+ if (!options.dryRun) await writeManifest(targetDir, answered);
5030
+ reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
5031
+ }
4539
5032
  if (scope === "user") {
4540
5033
  const fanout = await syncUserTierFanout({
4541
5034
  ownedClients: SPELL_FANOUT_CLIENTS,
@@ -4568,6 +5061,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4568
5061
  }
4569
5062
  if (manifest.components.length === 0) {
4570
5063
  console.log("No components installed. Nothing to update.");
5064
+ if (scope === "repo" && Object.keys(flagPatch).length > 0) {
5065
+ if (!options.dryRun) await writeManifest(targetDir, answered);
5066
+ reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
5067
+ }
4571
5068
  return;
4572
5069
  }
4573
5070
  let fileCount = 0;
@@ -4616,8 +5113,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4616
5113
  const updatedFiles = [];
4617
5114
  const fileHashes = {};
4618
5115
  for (const file of component.files) {
4619
- const srcPath = join9(assetsDir, component.sourceOverrides?.[file] ?? file);
4620
- const targetExists = await fileExists(join9(targetDir, file));
5116
+ const srcPath = join10(assetsDir, component.sourceOverrides?.[file] ?? file);
5117
+ const targetExists = await fileExists(join10(targetDir, file));
4621
5118
  const preserveExisting = Boolean(component.skipExisting) && targetExists;
4622
5119
  const recordedHash = installed.fileHashes?.[file];
4623
5120
  if (component.initOnly && !targetExists) {
@@ -4637,7 +5134,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4637
5134
  continue;
4638
5135
  }
4639
5136
  }
4640
- const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && !await fileMatchesHash(join9(targetDir, file), recordedHash);
5137
+ const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && !await fileMatchesHash(join10(targetDir, file), recordedHash);
4641
5138
  if (preserveExisting) {
4642
5139
  console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
4643
5140
  } else if (editedByOperator && isClientShimPath(file)) {
@@ -4664,11 +5161,11 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4664
5161
  handled = true;
4665
5162
  } else {
4666
5163
  const [currentContent, newContent] = await Promise.all([
4667
- readFile7(join9(targetDir, file), "utf-8"),
5164
+ readFile7(join10(targetDir, file), "utf-8"),
4668
5165
  readFile7(srcPath, "utf-8")
4669
5166
  ]);
4670
5167
  const result = merge3(oldVendorContent, currentContent, newContent);
4671
- await writeFile9(join9(targetDir, file), result.content, "utf-8");
5168
+ await writeFile9(join10(targetDir, file), result.content, "utf-8");
4672
5169
  fileHashes[file] = await hashFile(srcPath);
4673
5170
  if (result.hasConflict) {
4674
5171
  conflictedFiles.push(file);
@@ -4690,7 +5187,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4690
5187
  }
4691
5188
  }
4692
5189
  for (const dir of component.directories ?? []) {
4693
- const srcDirPath = join9(assetsDir, dir);
5190
+ const srcDirPath = join10(assetsDir, dir);
4694
5191
  if (options.dryRun) {
4695
5192
  console.log(` [dry-run] Would update directory: ${dir}/`);
4696
5193
  } else {
@@ -4757,6 +5254,7 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4757
5254
  console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
4758
5255
  }
4759
5256
  }
5257
+ printRegistryChanges();
4760
5258
  if (customizedShims.length > 0) {
4761
5259
  const verb = options.dryRun ? "[dry-run] Would keep" : "Kept";
4762
5260
  console.log(
@@ -4789,7 +5287,8 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4789
5287
  `
4790
5288
  [dry-run] Would update ${fileCount} files.`
4791
5289
  );
4792
- const applicableRetrofits = scope === "repo" ? MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest)) : [];
5290
+ if (Object.keys(flagPatch).length > 0) reportFlagPatch(flagPatch, manifest, answered, true);
5291
+ const applicableRetrofits = scope === "repo" ? MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(answered)) : [];
4793
5292
  if (applicableRetrofits.length > 0) {
4794
5293
  console.log(
4795
5294
  `[dry-run] Would ask ${applicableRetrofits.length} manifest retrofit question${applicableRetrofits.length === 1 ? "" : "s"}: ${applicableRetrofits.map((r) => r.field).join(", ")}.`
@@ -4798,19 +5297,21 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4798
5297
  return;
4799
5298
  }
4800
5299
  const interactive = Boolean(process.stdin.isTTY) && scope === "repo";
4801
- const retrofitPatch = interactive ? await runManifestRetrofits(manifest) : {};
5300
+ const retrofitPatch = interactive ? await runManifestRetrofits(answered, targetDir) : {};
4802
5301
  if (!interactive && scope === "repo") {
4803
- const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
5302
+ const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(answered));
4804
5303
  if (pending.length > 0) {
4805
5304
  console.log(
4806
- ` ! Skipped ${pending.length} manifest question${pending.length === 1 ? "" : "s"} (${pending.map((r) => r.field).join(", ")}) \u2014 no interactive terminal. Run \`spell update\` from a terminal to answer.`
5305
+ ` ! Skipped ${pending.length} manifest question${pending.length === 1 ? "" : "s"} (${pending.map((r) => r.field).join(", ")}) \u2014 no interactive terminal. Run \`spell update\` from a terminal, or answer by flags:`
4807
5306
  );
5307
+ for (const line of manifestFlagLine(answered) ?? []) console.log(` ${line}`);
4808
5308
  }
4809
5309
  }
4810
5310
  const updated = {
4811
5311
  ...manifest,
4812
5312
  version: packageVersion,
4813
5313
  components: updatedComponents,
5314
+ ...flagPatch,
4814
5315
  ...retrofitPatch,
4815
5316
  ...fanoutRecord !== void 0 ? { fanout: fanoutRecord } : {},
4816
5317
  ...defaultScopePatch(options, manifest)
@@ -4820,6 +5321,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4820
5321
  `
4821
5322
  \u2713 Updated ${fileCount} files.`
4822
5323
  );
5324
+ if (Object.keys(flagPatch).length > 0) {
5325
+ reportFlagPatch(flagPatch, manifest, updated, false);
5326
+ } else if (updated.push_policy !== manifest.push_policy) {
5327
+ for (const line of pushPolicyNotice(updated.push_policy)) console.log(line);
5328
+ }
4823
5329
  if (conflictedFiles.length > 0) {
4824
5330
  console.log(
4825
5331
  `
@@ -4829,7 +5335,13 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4829
5335
  );
4830
5336
  }
4831
5337
  if (scope === "repo" && manifest.role !== "hub" && updated.role === "hub") {
4832
- await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
5338
+ if (process.stdin.isTTY) {
5339
+ await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
5340
+ } else {
5341
+ console.log(
5342
+ ` This repository is now a hub. Run \`spell update\` from a terminal to scaffold ${updated.business_root ?? "ventures"}/registry.json from existing venture folders.`
5343
+ );
5344
+ }
4833
5345
  }
4834
5346
  }
4835
5347
 
@@ -4838,7 +5350,7 @@ import { dirname as dirname7 } from "path";
4838
5350
 
4839
5351
  // src/modules/version-check.ts
4840
5352
  import { readFile as readFile8, writeFile as writeFile10, mkdir as mkdir6 } from "fs/promises";
4841
- import { dirname as dirname6, join as join10 } from "path";
5353
+ import { dirname as dirname6, join as join11 } from "path";
4842
5354
  import { homedir as homedir3 } from "os";
4843
5355
  import { createRequire } from "module";
4844
5356
  var require2 = createRequire(import.meta.url);
@@ -4852,7 +5364,7 @@ function getFeedUrl() {
4852
5364
  }
4853
5365
  return url;
4854
5366
  }
4855
- var DEFAULT_CACHE_PATH = join10(homedir3(), ".arcane", "version-cache.json");
5367
+ var DEFAULT_CACHE_PATH = join11(homedir3(), ".arcane", "version-cache.json");
4856
5368
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
4857
5369
  async function readCache(cachePath) {
4858
5370
  try {
@@ -5071,7 +5583,7 @@ async function runStatus(targetDir, packageVersion, options = {}) {
5071
5583
 
5072
5584
  // src/commands/uninstall.ts
5073
5585
  import { rm as rm5 } from "fs/promises";
5074
- import { dirname as dirname8, join as join11 } from "path";
5586
+ import { dirname as dirname8, join as join12 } from "path";
5075
5587
  import { confirm as confirm3 } from "@inquirer/prompts";
5076
5588
  async function runUninstall(options, targetDir) {
5077
5589
  let manifest;
@@ -5154,16 +5666,16 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
5154
5666
  }
5155
5667
  }
5156
5668
  }
5157
- await rm5(join11(targetDir, ".arcane.json"), { force: true });
5669
+ await rm5(join12(targetDir, ".arcane.json"), { force: true });
5158
5670
  const agentDirs = [
5159
5671
  ".github/agents",
5160
5672
  ".arcane/agents",
5161
5673
  ".arcane/generated"
5162
5674
  ];
5163
5675
  for (const dir of agentDirs) {
5164
- await rm5(join11(targetDir, dir), { recursive: true, force: true });
5676
+ await rm5(join12(targetDir, dir), { recursive: true, force: true });
5165
5677
  }
5166
- await rm5(join11(targetDir, ".arcane/agents.yaml"), { force: true });
5678
+ await rm5(join12(targetDir, ".arcane/agents.yaml"), { force: true });
5167
5679
  const markerFiles = [
5168
5680
  "CLAUDE.md",
5169
5681
  "AGENTS.md",
@@ -5225,10 +5737,10 @@ async function uninstallUserTier(options, storeRoot, manifest) {
5225
5737
  console.log(` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`);
5226
5738
  }
5227
5739
  }
5228
- await rm5(join11(storeRoot, ".arcane.json"), { force: true });
5229
- await rm5(join11(storeRoot, "agents"), { recursive: true, force: true });
5230
- await rm5(join11(storeRoot, "agents.yaml"), { force: true });
5231
- await removeDirectoryIfEmpty(join11(storeRoot, USER_TIER_SPELLS_DIR));
5740
+ await rm5(join12(storeRoot, ".arcane.json"), { force: true });
5741
+ await rm5(join12(storeRoot, "agents"), { recursive: true, force: true });
5742
+ await rm5(join12(storeRoot, "agents.yaml"), { force: true });
5743
+ await removeDirectoryIfEmpty(join12(storeRoot, USER_TIER_SPELLS_DIR));
5232
5744
  await removeDirectoryIfEmpty(storeRoot);
5233
5745
  for (const line of describeFanoutOutcomes(fanout.outcomes)) console.log(` ${line}`);
5234
5746
  const pruned = fanout.outcomes.filter((o) => o.status === "pruned").length;
@@ -5240,8 +5752,8 @@ async function uninstallUserTier(options, storeRoot, manifest) {
5240
5752
  }
5241
5753
 
5242
5754
  // src/commands/doctor.ts
5243
- import { access as access4, readdir as readdir8, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
5244
- import { join as join13, dirname as dirname9 } from "path";
5755
+ import { access as access4, readdir as readdir9, readFile as readFile11, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
5756
+ import { join as join15, dirname as dirname9 } from "path";
5245
5757
 
5246
5758
  // src/config/incidents.ts
5247
5759
  var INCIDENT_QUEUE = [
@@ -5310,7 +5822,7 @@ function evaluateIncidentGate(records) {
5310
5822
 
5311
5823
  // src/modules/denylist-scan.ts
5312
5824
  import { readFile as readFile9, readdir as readdir7 } from "fs/promises";
5313
- import { join as join12, relative } from "path";
5825
+ import { join as join13, relative } from "path";
5314
5826
  function escapeRegExp(value) {
5315
5827
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5316
5828
  }
@@ -5361,7 +5873,7 @@ async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT
5361
5873
  return collected;
5362
5874
  }
5363
5875
  for (const entry of entries) {
5364
- const absolutePath = join12(root, entry.name);
5876
+ const absolutePath = join13(root, entry.name);
5365
5877
  if (entry.isDirectory()) {
5366
5878
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
5367
5879
  collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
@@ -5576,6 +6088,133 @@ function parseAdoRemote(url) {
5576
6088
  return null;
5577
6089
  }
5578
6090
 
6091
+ // src/modules/placeholders.ts
6092
+ import { readdir as readdir8, readFile as readFile10 } from "fs/promises";
6093
+ import { join as join14 } from "path";
6094
+ var PLACEHOLDER_STANDARD_FILE = "spell-authoring-standards.md";
6095
+ var RUNTIME_PLACEHOLDERS_START = "<!-- runtime-placeholders:start -->";
6096
+ var RUNTIME_PLACEHOLDERS_END = "<!-- runtime-placeholders:end -->";
6097
+ var PLACEHOLDER_PATTERN = /(?<!\$)\{([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)\}/g;
6098
+ var FRONTMATTER_PATTERN2 = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
6099
+ function parseFrontmatterStatus(content) {
6100
+ const block = FRONTMATTER_PATTERN2.exec(content);
6101
+ if (!block) return null;
6102
+ const status = /^status:[ \t]*["']?([^"'\s#]+)/m.exec(block[1]);
6103
+ return status ? status[1] : null;
6104
+ }
6105
+ function stripFrontmatter(content) {
6106
+ const block = FRONTMATTER_PATTERN2.exec(content);
6107
+ return block ? content.slice(block[0].length) : content;
6108
+ }
6109
+ function stripFencedCode(text) {
6110
+ const kept = [];
6111
+ let fence = null;
6112
+ for (const line of text.split(/\r?\n/)) {
6113
+ if (fence) {
6114
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
6115
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = null;
6116
+ kept.push("");
6117
+ continue;
6118
+ }
6119
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
6120
+ if (open && !(open[1][0] === "`" && line.slice(open[0].length).includes("`"))) {
6121
+ fence = { char: open[1][0], length: open[1].length };
6122
+ kept.push("");
6123
+ continue;
6124
+ }
6125
+ kept.push(line);
6126
+ }
6127
+ return kept.join("\n");
6128
+ }
6129
+ function stripInlineCode(text) {
6130
+ return text.split(/\n[ \t]*\n/).map((paragraph) => {
6131
+ let out = "";
6132
+ let i = 0;
6133
+ while (i < paragraph.length) {
6134
+ if (paragraph[i] !== "`") {
6135
+ out += paragraph[i];
6136
+ i += 1;
6137
+ continue;
6138
+ }
6139
+ let runEnd = i;
6140
+ while (paragraph[runEnd] === "`") runEnd += 1;
6141
+ const run = runEnd - i;
6142
+ let close = -1;
6143
+ let j = runEnd;
6144
+ while (j < paragraph.length) {
6145
+ if (paragraph[j] !== "`") {
6146
+ j += 1;
6147
+ continue;
6148
+ }
6149
+ let k = j;
6150
+ while (paragraph[k] === "`") k += 1;
6151
+ if (k - j === run) {
6152
+ close = j;
6153
+ break;
6154
+ }
6155
+ j = k;
6156
+ }
6157
+ if (close === -1) {
6158
+ out += paragraph.slice(i, runEnd);
6159
+ i = runEnd;
6160
+ } else {
6161
+ out += " ";
6162
+ i = close + run;
6163
+ }
6164
+ }
6165
+ return out;
6166
+ }).join("\n\n");
6167
+ }
6168
+ function findPlaceholderTokens(content) {
6169
+ const prose = stripInlineCode(stripFencedCode(stripFrontmatter(content)));
6170
+ const seen = /* @__PURE__ */ new Set();
6171
+ for (const match of prose.matchAll(PLACEHOLDER_PATTERN)) seen.add(`{${match[1]}}`);
6172
+ return [...seen];
6173
+ }
6174
+ function parseRuntimePlaceholders(standardContent) {
6175
+ const start = standardContent.indexOf(RUNTIME_PLACEHOLDERS_START);
6176
+ const end = standardContent.indexOf(RUNTIME_PLACEHOLDERS_END);
6177
+ if (start === -1 || end === -1 || end < start) return null;
6178
+ const region = standardContent.slice(start + RUNTIME_PLACEHOLDERS_START.length, end);
6179
+ const tokens = /* @__PURE__ */ new Set();
6180
+ for (const match of region.matchAll(/^[ \t]*[-*][ \t]+`(\{[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*\})`/gm)) {
6181
+ tokens.add(match[1]);
6182
+ }
6183
+ return tokens;
6184
+ }
6185
+ async function scanGovernancePlaceholders(governanceDir) {
6186
+ let standard;
6187
+ try {
6188
+ standard = await readFile10(join14(governanceDir, PLACEHOLDER_STANDARD_FILE), "utf8");
6189
+ } catch (err) {
6190
+ if (err.code === "ENOENT") {
6191
+ return { status: "skipped", reason: `${PLACEHOLDER_STANDARD_FILE} is not installed` };
6192
+ }
6193
+ throw err;
6194
+ }
6195
+ const runtimeTokens = parseRuntimePlaceholders(standard);
6196
+ if (!runtimeTokens) {
6197
+ return {
6198
+ status: "skipped",
6199
+ reason: `${PLACEHOLDER_STANDARD_FILE} has no runtime-placeholders list (an older version?)`
6200
+ };
6201
+ }
6202
+ const files = (await readdir8(governanceDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name).sort();
6203
+ const activeDocs = [];
6204
+ const templateDocs = [];
6205
+ const findings = [];
6206
+ for (const file of files) {
6207
+ const content = await readFile10(join14(governanceDir, file), "utf8");
6208
+ const status = parseFrontmatterStatus(content);
6209
+ if (status === "template") templateDocs.push(file);
6210
+ if (status !== "active") continue;
6211
+ activeDocs.push(file);
6212
+ const unknown = findPlaceholderTokens(content).filter((token) => !runtimeTokens.has(token));
6213
+ if (unknown.length > 0) findings.push({ file, tokens: unknown });
6214
+ }
6215
+ return { status: "scanned", runtimeTokens, activeDocs, templateDocs, findings };
6216
+ }
6217
+
5579
6218
  // src/commands/doctor.ts
5580
6219
  function isRecord3(value) {
5581
6220
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -5596,14 +6235,14 @@ async function checkNodeVersion() {
5596
6235
  async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
5597
6236
  const name = `VS Code extension: ${label}`;
5598
6237
  const flavours = [
5599
- { dir: join13(homeDir, ".vscode", "extensions"), bin: "code" },
5600
- { dir: join13(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
6238
+ { dir: join15(homeDir, ".vscode", "extensions"), bin: "code" },
6239
+ { dir: join15(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
5601
6240
  ];
5602
6241
  let installBin = null;
5603
6242
  for (const flavour of flavours) {
5604
6243
  let entries;
5605
6244
  try {
5606
- entries = await readdir8(flavour.dir);
6245
+ entries = await readdir9(flavour.dir);
5607
6246
  } catch {
5608
6247
  continue;
5609
6248
  }
@@ -5625,13 +6264,13 @@ async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
5625
6264
  }
5626
6265
  async function checkArcaneManifest(targetDir) {
5627
6266
  const name = "Arcane manifest (.arcane.json)";
5628
- const manifestPath2 = join13(targetDir, ".arcane.json");
6267
+ const manifestPath2 = join15(targetDir, ".arcane.json");
5629
6268
  try {
5630
6269
  await access4(manifestPath2);
5631
6270
  } catch {
5632
- const selfHostedManifestPath = join13(targetDir, "src", "assets", ".arcane.json");
6271
+ const selfHostedManifestPath = join15(targetDir, "src", "assets", ".arcane.json");
5633
6272
  try {
5634
- const raw = await readFile10(selfHostedManifestPath, "utf8");
6273
+ const raw = await readFile11(selfHostedManifestPath, "utf8");
5635
6274
  const manifest = JSON.parse(raw);
5636
6275
  if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
5637
6276
  return {
@@ -5650,7 +6289,7 @@ async function checkArcaneManifest(targetDir) {
5650
6289
  };
5651
6290
  }
5652
6291
  try {
5653
- const raw = await readFile10(manifestPath2, "utf8");
6292
+ const raw = await readFile11(manifestPath2, "utf8");
5654
6293
  const manifest = JSON.parse(raw);
5655
6294
  if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
5656
6295
  return {
@@ -5735,7 +6374,7 @@ async function checkSessionContinuity(targetDir) {
5735
6374
  const results = [];
5736
6375
  for (const file of SESSION_CONTINUITY_FILES) {
5737
6376
  const name = `Session file: ${file}`;
5738
- const filePath = join13(targetDir, file);
6377
+ const filePath = join15(targetDir, file);
5739
6378
  try {
5740
6379
  await access4(filePath);
5741
6380
  results.push({ name, passed: true, message: "present" });
@@ -5753,11 +6392,11 @@ async function checkSessionContinuity(targetDir) {
5753
6392
  async function fixSessionContinuity(targetDir, assetsDir) {
5754
6393
  const created = [];
5755
6394
  for (const file of SESSION_CONTINUITY_FILES) {
5756
- const destPath = join13(targetDir, file);
6395
+ const destPath = join15(targetDir, file);
5757
6396
  try {
5758
6397
  await access4(destPath);
5759
6398
  } catch {
5760
- const srcPath = join13(assetsDir, file);
6399
+ const srcPath = join15(assetsDir, file);
5761
6400
  await mkdir7(dirname9(destPath), { recursive: true });
5762
6401
  await fsCopyFile(srcPath, destPath);
5763
6402
  created.push(file);
@@ -5877,15 +6516,73 @@ async function checkPushPolicy(targetDir) {
5877
6516
  name,
5878
6517
  passed: false,
5879
6518
  blocking: false,
5880
- message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have.`
6519
+ // ARC-049 decision 3: name the command that closes the gap.
6520
+ 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.`
6521
+ };
6522
+ }
6523
+ async function checkGovernancePlaceholders(targetDir) {
6524
+ const name = "Governance placeholders (ARC-051)";
6525
+ let scan;
6526
+ try {
6527
+ scan = await scanGovernancePlaceholders(join15(targetDir, ".arcane", "governance"));
6528
+ } catch (err) {
6529
+ return {
6530
+ name,
6531
+ passed: false,
6532
+ blocking: false,
6533
+ message: `could not scan .arcane/governance (${err instanceof Error ? err.message : String(err)})`
6534
+ };
6535
+ }
6536
+ if (scan.status === "skipped") {
6537
+ return { name, passed: true, blocking: false, skipped: true, message: `skipped \u2014 ${scan.reason}` };
6538
+ }
6539
+ if (scan.findings.length === 0) {
6540
+ return {
6541
+ name,
6542
+ passed: true,
6543
+ blocking: false,
6544
+ message: `${scan.activeDocs.length} active doc(s) scanned, no unknown placeholders`
6545
+ };
6546
+ }
6547
+ const listed = scan.findings.map(({ file, tokens }) => `.arcane/governance/${file}: ${tokens.join(", ")}`);
6548
+ return {
6549
+ name,
6550
+ passed: false,
6551
+ blocking: false,
6552
+ 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\`.`
6553
+ };
6554
+ }
6555
+ async function checkComponentRequires(targetDir) {
6556
+ const name = "Component prerequisites";
6557
+ let manifest;
6558
+ try {
6559
+ manifest = await readManifest(targetDir);
6560
+ } catch {
6561
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
6562
+ }
6563
+ if (manifest.scope === "user") {
6564
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 the user tier holds spells only" };
6565
+ }
6566
+ if (!Array.isArray(manifest.components)) {
6567
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 .arcane.json lists no components" };
6568
+ }
6569
+ const missing = findMissingRequires(manifest.components, manifest.profile);
6570
+ if (missing.length === 0) {
6571
+ return { name, passed: true, blocking: false, message: "every installed component's prerequisites are installed" };
6572
+ }
6573
+ return {
6574
+ name,
6575
+ passed: false,
6576
+ blocking: false,
6577
+ message: missing.map(({ name: required, requiredBy }) => `${requiredBy.join(", ")} cites ${required}, which is not installed \u2014 \`spell add ${required}\``).join("; ")
5881
6578
  };
5882
6579
  }
5883
6580
  async function checkMcpConfig(targetDir) {
5884
6581
  const name = "MCP config (per-server timeout, I12)";
5885
- const filePath = join13(targetDir, ".mcp.json");
6582
+ const filePath = join15(targetDir, ".mcp.json");
5886
6583
  let raw;
5887
6584
  try {
5888
- raw = await readFile10(filePath, "utf8");
6585
+ raw = await readFile11(filePath, "utf8");
5889
6586
  } catch {
5890
6587
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
5891
6588
  }
@@ -5938,10 +6635,10 @@ function isDelegationsFile(value) {
5938
6635
  }
5939
6636
  async function checkDelegations(targetDir) {
5940
6637
  const name = "Standing delegations (T13)";
5941
- const filePath = join13(targetDir, ".arcane", "delegations.json");
6638
+ const filePath = join15(targetDir, ".arcane", "delegations.json");
5942
6639
  let raw;
5943
6640
  try {
5944
- raw = await readFile10(filePath, "utf8");
6641
+ raw = await readFile11(filePath, "utf8");
5945
6642
  } catch {
5946
6643
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
5947
6644
  }
@@ -6051,7 +6748,7 @@ async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
6051
6748
  let probed = false;
6052
6749
  const probe = "spell-status.md";
6053
6750
  try {
6054
- const entries = await readdir8(join13(storeRoot, USER_TIER_SPELLS_DIR));
6751
+ const entries = await readdir9(join15(storeRoot, USER_TIER_SPELLS_DIR));
6055
6752
  const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
6056
6753
  spellCount = spells.length;
6057
6754
  probed = spells.includes(probe);
@@ -6114,16 +6811,19 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6114
6811
  checkDelegations(targetDir),
6115
6812
  checkMcpConfig(targetDir),
6116
6813
  checkUserTier(resolveHomeDir(), options.packageVersion),
6117
- checkSpellScope(targetDir)
6814
+ checkSpellScope(targetDir),
6815
+ checkGovernancePlaceholders(targetDir),
6816
+ checkComponentRequires(targetDir)
6118
6817
  ]);
6119
6818
  const sessionResults = await checkSessionContinuity(targetDir);
6120
6819
  results.push(...sessionResults);
6121
6820
  let allPassed = true;
6122
6821
  for (const result of results) {
6123
- const icon = result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
6124
- const label = result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
6822
+ const skipped = result.passed && result.skipped === true;
6823
+ const icon = skipped ? "\u2013" : result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
6824
+ const label = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
6125
6825
  console.log(` ${icon} [${label}] ${result.name}`);
6126
- if (!result.passed) {
6826
+ if (!result.passed || skipped) {
6127
6827
  console.log(` ${result.message}`);
6128
6828
  }
6129
6829
  if (!result.passed && result.blocking !== false) {
@@ -6157,16 +6857,16 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6157
6857
  }
6158
6858
 
6159
6859
  // src/commands/ward.ts
6160
- import { readFile as readFile12 } from "fs/promises";
6860
+ import { readFile as readFile13 } from "fs/promises";
6161
6861
 
6162
6862
  // src/modules/ward.ts
6163
- import { readdir as readdir9, readFile as readFile11 } from "fs/promises";
6164
- import { join as join14, relative as relative2 } from "path";
6863
+ import { readdir as readdir10, readFile as readFile12 } from "fs/promises";
6864
+ import { join as join16, relative as relative2 } from "path";
6165
6865
  async function scanFileAllRules(absolutePath, displayPath, rules) {
6166
6866
  const findings = [];
6167
6867
  let content;
6168
6868
  try {
6169
- content = await readFile11(absolutePath, "utf8");
6869
+ content = await readFile12(absolutePath, "utf8");
6170
6870
  } catch {
6171
6871
  return findings;
6172
6872
  }
@@ -6219,12 +6919,12 @@ async function scanFilenames(root, rules, repoRoot = root) {
6219
6919
  const findings = [];
6220
6920
  let entries;
6221
6921
  try {
6222
- entries = await readdir9(root, { withFileTypes: true });
6922
+ entries = await readdir10(root, { withFileTypes: true });
6223
6923
  } catch {
6224
6924
  return findings;
6225
6925
  }
6226
6926
  for (const entry of entries) {
6227
- const absolutePath = join14(root, entry.name);
6927
+ const absolutePath = join16(root, entry.name);
6228
6928
  if (entry.isDirectory()) {
6229
6929
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
6230
6930
  findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
@@ -6261,7 +6961,7 @@ async function runWard(repoRoot, options = {}) {
6261
6961
  const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
6262
6962
  if (findings2.length > 0) {
6263
6963
  try {
6264
- fileLines.set(displayPath, (await readFile11(absolutePath, "utf8")).split("\n"));
6964
+ fileLines.set(displayPath, (await readFile12(absolutePath, "utf8")).split("\n"));
6265
6965
  } catch {
6266
6966
  }
6267
6967
  }
@@ -6289,7 +6989,7 @@ function parseTermsFileContent(raw) {
6289
6989
  }
6290
6990
  async function readTermsFile(filePath) {
6291
6991
  try {
6292
- return parseTermsFileContent(await readFile12(filePath, "utf8"));
6992
+ return parseTermsFileContent(await readFile13(filePath, "utf8"));
6293
6993
  } catch (error) {
6294
6994
  console.warn(
6295
6995
  `\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
@@ -6436,17 +7136,80 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
6436
7136
  );
6437
7137
  }
6438
7138
 
7139
+ // src/commands/block-push.ts
7140
+ async function runBlockPush(targetDir) {
7141
+ let manifest;
7142
+ try {
7143
+ manifest = await readManifest(targetDir);
7144
+ } catch (err) {
7145
+ if (err instanceof ManifestNotFoundError) {
7146
+ console.error('Not initialized. Run "spell init" first.');
7147
+ process.exit(1);
7148
+ return;
7149
+ }
7150
+ throw err;
7151
+ }
7152
+ if (manifest.scope === "user") {
7153
+ console.error(
7154
+ "spell block-push applies to a repository, not the user tier: push_policy describes a repository."
7155
+ );
7156
+ process.exit(1);
7157
+ return;
7158
+ }
7159
+ if ((await inspectGitRepository(targetDir)).status === "not-repository") {
7160
+ console.error("spell block-push needs a Git repository: there is nothing here to block.");
7161
+ process.exit(1);
7162
+ return;
7163
+ }
7164
+ const recorded = manifest.push_policy ?? "open";
7165
+ const alreadyEnforced = recorded === "blocked" && await isHookEnforced(targetDir) && (await undisabledRemotes(targetDir)).length === 0;
7166
+ if (alreadyEnforced) {
7167
+ printSuccess("Push is already blocked and enforced here. Nothing changed.");
7168
+ if ((await listRemotes(targetDir)).length === 0) {
7169
+ printWarning(
7170
+ "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."
7171
+ );
7172
+ }
7173
+ printInfo("Run `spell unblock-push` from a terminal to undo this.");
7174
+ return;
7175
+ }
7176
+ const outcome = await applyBlockedPushControls(targetDir, {
7177
+ stopOnHookRefusal: true,
7178
+ emit: (m) => m.level === "warning" ? printWarning(m.text) : printInfo(m.text)
7179
+ });
7180
+ const failedRemotes = outcome.urls.filter((u) => u.status === "failed");
7181
+ if (!hookInForce(outcome.hook) || failedRemotes.length > 0) {
7182
+ printWarning(
7183
+ 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`."
7184
+ );
7185
+ if (recorded !== "blocked") {
7186
+ printInfo(
7187
+ `The manifest still records push_policy: "${recorded}" \u2014 it is not changed to "blocked" until both controls are in force.`
7188
+ );
7189
+ }
7190
+ process.exit(1);
7191
+ return;
7192
+ }
7193
+ if (recorded !== "blocked") {
7194
+ await writeManifest(targetDir, { ...manifest, push_policy: "blocked" });
7195
+ printSuccess(`Push blocked. Recorded push_policy: "blocked" (was "${recorded}") in .arcane.json.`);
7196
+ printInfo("Commit that change so the repository's posture stays visible to everyone.");
7197
+ } else {
7198
+ printSuccess('Push blocked. The manifest already recorded push_policy: "blocked"; it is now enforced.');
7199
+ }
7200
+ }
7201
+
6439
7202
  // src/commands/report.ts
6440
7203
  import { access as access5 } from "fs/promises";
6441
- import { join as join17 } from "path";
7204
+ import { join as join19 } from "path";
6442
7205
 
6443
7206
  // src/modules/show-report/generate.ts
6444
- import { mkdir as mkdir8, readdir as readdir10, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
6445
- import { basename as basename3, dirname as dirname10, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
7207
+ import { mkdir as mkdir8, readdir as readdir11, readFile as readFile15, writeFile as writeFile11 } from "fs/promises";
7208
+ import { basename as basename3, dirname as dirname10, isAbsolute, join as join18, relative as relative3, resolve as resolve4 } from "path";
6446
7209
 
6447
7210
  // src/modules/show-report/model.ts
6448
- import { readFile as readFile13 } from "fs/promises";
6449
- import { join as join15 } from "path";
7211
+ import { readFile as readFile14 } from "fs/promises";
7212
+ import { join as join17 } from "path";
6450
7213
 
6451
7214
  // src/modules/show-report/plan-parser.ts
6452
7215
  function parseFrontmatter(content) {
@@ -6716,7 +7479,7 @@ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
6716
7479
  // src/modules/show-report/model.ts
6717
7480
  async function readOptional(path4) {
6718
7481
  try {
6719
- return await readFile13(path4, "utf8");
7482
+ return await readFile14(path4, "utf8");
6720
7483
  } catch {
6721
7484
  return null;
6722
7485
  }
@@ -6812,13 +7575,13 @@ function buildCorrections(ledgerRows, programName) {
6812
7575
  };
6813
7576
  }
6814
7577
  async function buildShowReportModel(options) {
6815
- const planAbsPath = join15(options.rootDir, options.planRelPath);
6816
- const queueAbsPath = join15(options.rootDir, options.queueRelPath);
7578
+ const planAbsPath = join17(options.rootDir, options.planRelPath);
7579
+ const queueAbsPath = join17(options.rootDir, options.queueRelPath);
6817
7580
  const ledgerRelPath = "docs/verification-ledger.md";
6818
- const ledgerAbsPath = join15(options.rootDir, ledgerRelPath);
6819
- const decisionsAbsPath = join15(options.rootDir, "DECISIONS.md");
7581
+ const ledgerAbsPath = join17(options.rootDir, ledgerRelPath);
7582
+ const decisionsAbsPath = join17(options.rootDir, "DECISIONS.md");
6820
7583
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
6821
- readFile13(planAbsPath, "utf8"),
7584
+ readFile14(planAbsPath, "utf8"),
6822
7585
  readOptional(queueAbsPath),
6823
7586
  readOptional(ledgerAbsPath),
6824
7587
  readOptional(decisionsAbsPath)
@@ -6987,7 +7750,7 @@ function toPosix(path4) {
6987
7750
  async function programFromPlan(rootDir, slug, planRelPath) {
6988
7751
  let planContent;
6989
7752
  try {
6990
- planContent = await readFile14(join16(rootDir, planRelPath), "utf8");
7753
+ planContent = await readFile15(join18(rootDir, planRelPath), "utf8");
6991
7754
  } catch {
6992
7755
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
6993
7756
  }
@@ -6999,16 +7762,16 @@ async function programFromPlan(rootDir, slug, planRelPath) {
6999
7762
  program: {
7000
7763
  slug,
7001
7764
  planRelPath,
7002
- queueRelPath: toPosix(join16(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
7765
+ queueRelPath: toPosix(join18(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
7003
7766
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
7004
7767
  }
7005
7768
  };
7006
7769
  }
7007
7770
  async function discoverPrograms(rootDir) {
7008
- const plansDir = join16(rootDir, "docs", "plans");
7771
+ const plansDir = join18(rootDir, "docs", "plans");
7009
7772
  let entries;
7010
7773
  try {
7011
- entries = (await readdir10(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
7774
+ entries = (await readdir11(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
7012
7775
  } catch (error) {
7013
7776
  if (error.code === "ENOENT") return { programs: [], skipped: [] };
7014
7777
  throw error;
@@ -7032,14 +7795,14 @@ function normalizeLineEndings2(content) {
7032
7795
  }
7033
7796
  async function readOptionalFile(path4) {
7034
7797
  try {
7035
- return await readFile14(path4, "utf8");
7798
+ return await readFile15(path4, "utf8");
7036
7799
  } catch (error) {
7037
7800
  if (error.code === "ENOENT") return null;
7038
7801
  throw error;
7039
7802
  }
7040
7803
  }
7041
7804
  async function generateReport(rootDir, program2, templateHtml) {
7042
- const planContent = await readFile14(join16(rootDir, program2.planRelPath), "utf8");
7805
+ const planContent = await readFile15(join18(rootDir, program2.planRelPath), "utf8");
7043
7806
  const frontmatter = parseFrontmatter(planContent);
7044
7807
  const model = await buildShowReportModel({
7045
7808
  rootDir,
@@ -7057,7 +7820,7 @@ async function generateReport(rootDir, program2, templateHtml) {
7057
7820
  };
7058
7821
  }
7059
7822
  async function runReportGeneration(options) {
7060
- const templateHtml = await readFile14(options.templatePath, "utf8");
7823
+ const templateHtml = await readFile15(options.templatePath, "utf8");
7061
7824
  const drifted = [];
7062
7825
  const repaired = [];
7063
7826
  const unwritten = [];
@@ -7072,12 +7835,12 @@ async function runReportGeneration(options) {
7072
7835
  [`${outRel}/show-report.html`, generated.html]
7073
7836
  ];
7074
7837
  for (const [relPath, expected] of outputs) {
7075
- const actual = await readOptionalFile(join16(options.rootDir, relPath));
7838
+ const actual = await readOptionalFile(join18(options.rootDir, relPath));
7076
7839
  if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
7077
7840
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
7078
7841
  if (options.mode === "write") {
7079
7842
  await mkdir8(outAbs, { recursive: true });
7080
- await writeFile11(join16(options.rootDir, relPath), expected, "utf8");
7843
+ await writeFile11(join18(options.rootDir, relPath), expected, "utf8");
7081
7844
  repaired.push(relPath);
7082
7845
  }
7083
7846
  }
@@ -7088,7 +7851,7 @@ async function runReportGeneration(options) {
7088
7851
 
7089
7852
  // src/commands/report.ts
7090
7853
  async function runReport(targetDir, options = {}, assetsDir) {
7091
- const templatePath = join17(assetsDir, "report", "show-report.template.html");
7854
+ const templatePath = join19(assetsDir, "report", "show-report.template.html");
7092
7855
  try {
7093
7856
  await access5(templatePath);
7094
7857
  } catch {
@@ -7151,13 +7914,29 @@ async function runReport(targetDir, options = {}, assetsDir) {
7151
7914
  import { realpathSync } from "fs";
7152
7915
  var require3 = createRequire2(import.meta.url);
7153
7916
  var pkg = require3("../package.json");
7154
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join18(dirname11(fileURLToPath(import.meta.url)), "assets");
7917
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join20(dirname11(fileURLToPath(import.meta.url)), "assets");
7155
7918
  var program = new Command();
7156
7919
  var USER_FLAG_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 spells shared by every repository on this machine, fanned out to ~/.agents/skills (Codex, Copilot) and ~/.claude/commands (Claude Code)";
7157
7920
  var USER_TIER_AGENTS_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 one agent roster shared by every repository on this machine, rendered to ~/.copilot/agents (VS Code agent modes)";
7158
7921
  function targetDirFor(opts) {
7159
7922
  return opts.user ? userTierRoot() : process.cwd();
7160
7923
  }
7924
+ function withManifestFlags(command) {
7925
+ 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(
7926
+ "--push-policy <policy>",
7927
+ "Answer the push question: open | guarded | blocked. Can tighten a recorded policy, never loosen it (use `spell unblock-push`)"
7928
+ ).option("--subject-root <path>", "Answer the subject question with a directory (docs profile)");
7929
+ }
7930
+ function manifestFlagsFrom(opts) {
7931
+ return {
7932
+ role: opts.role,
7933
+ trackingMode: opts.trackingMode,
7934
+ externalProvider: opts.externalProvider,
7935
+ contentSensitivity: opts.contentSensitivity,
7936
+ pushPolicy: opts.pushPolicy,
7937
+ subjectRoot: opts.subjectRoot
7938
+ };
7939
+ }
7161
7940
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
7162
7941
  if (program.args.length > 0) {
7163
7942
  const [attempted] = program.args;
@@ -7177,14 +7956,17 @@ program.name("spell").description("Arcane framework CLI \u2014 scaffold and mana
7177
7956
  await printWelcome(pkg.version, process.cwd());
7178
7957
  program.outputHelp();
7179
7958
  });
7180
- program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | docs | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").option("--user", USER_FLAG_DESCRIPTION).action(
7959
+ withManifestFlags(
7960
+ 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)
7961
+ ).action(
7181
7962
  async (opts) => {
7182
7963
  await runInit(
7183
7964
  {
7184
7965
  profile: opts.profile,
7185
7966
  force: opts.force,
7186
7967
  dryRun: opts.dryRun,
7187
- user: opts.user
7968
+ user: opts.user,
7969
+ manifestFlags: manifestFlagsFrom(opts)
7188
7970
  },
7189
7971
  targetDirFor(opts),
7190
7972
  ASSETS_DIR,
@@ -7203,12 +7985,14 @@ program.command("add <component>").description("Add a component to an existing A
7203
7985
  );
7204
7986
  }
7205
7987
  );
7206
- program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
7207
- "--prune",
7208
- "Also delete orphaned managed files (tracked before this update, no longer part of any current component). Hash-checked: an edited file is reported, never silently deleted"
7209
- ).option("--user", USER_FLAG_DESCRIPTION).option(
7210
- "--default-scope <scope>",
7211
- "With --user: remember repo | user as the answer `spell init` pre-selects for NEW repositories on this machine. Never rewrites an existing repository, and the question is still asked"
7988
+ withManifestFlags(
7989
+ program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
7990
+ "--prune",
7991
+ "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"
7992
+ ).option("--user", USER_FLAG_DESCRIPTION).option(
7993
+ "--default-scope <scope>",
7994
+ "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"
7995
+ )
7212
7996
  ).action(async (opts) => {
7213
7997
  if (opts.defaultScope !== void 0) {
7214
7998
  if (!opts.user) {
@@ -7229,6 +8013,7 @@ program.command("update").description("Update installed components to the curren
7229
8013
  dryRun: opts.dryRun,
7230
8014
  prune: opts.prune,
7231
8015
  user: opts.user,
8016
+ manifestFlags: manifestFlagsFrom(opts),
7232
8017
  ...opts.defaultScope === void 0 ? {} : { defaultScope: opts.defaultScope }
7233
8018
  },
7234
8019
  targetDirFor(opts),
@@ -7242,6 +8027,9 @@ program.command("status").description("Show installed components and check for u
7242
8027
  program.command("uninstall").description("Remove all installed Arcane framework files").option("--yes", "Skip confirmation prompt").option("--dry-run", "Preview what would be removed without deleting files").option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
7243
8028
  await runUninstall({ yes: opts.yes, dryRun: opts.dryRun, user: opts.user }, targetDirFor(opts));
7244
8029
  });
8030
+ program.command("block-push").description("Enforce push_policy: blocked on this repository (pre-push hook + disabled push URLs); only ever tightens").action(async () => {
8031
+ await runBlockPush(process.cwd());
8032
+ });
7245
8033
  program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
7246
8034
  await runUnblockPush(process.cwd());
7247
8035
  });