arcane-cli 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,11 +16,17 @@ import chalk2 from "chalk";
16
16
  // src/modules/copier.ts
17
17
  import {
18
18
  mkdir,
19
- copyFile as fsCopyFile,
20
19
  access,
21
- readdir
20
+ readdir,
21
+ readFile,
22
+ writeFile
22
23
  } from "fs/promises";
24
+ import { createHash } from "crypto";
23
25
  import path from "path";
26
+ async function hashFile(filePath) {
27
+ const content = await readFile(filePath);
28
+ return createHash("sha256").update(content).digest("hex");
29
+ }
24
30
  function validateTargetPath(targetDir, relativePath) {
25
31
  const resolvedBase = path.resolve(targetDir);
26
32
  const resolvedFull = path.resolve(targetDir, relativePath);
@@ -50,7 +56,10 @@ async function copyFile(srcPath, targetDir, relativePath, opts = {}) {
50
56
  `Destination already exists: "${relativePath}". Use --force to overwrite.`
51
57
  );
52
58
  }
53
- await fsCopyFile(srcPath, destPath);
59
+ const content = await readFile(srcPath);
60
+ const hash = createHash("sha256").update(content).digest("hex");
61
+ await writeFile(destPath, content);
62
+ return hash;
54
63
  }
55
64
  async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
56
65
  validateTargetPath(targetDir, relativeDir);
@@ -63,15 +72,15 @@ async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
63
72
  const subFiles = await copyDirectory(srcEntryPath, targetDir, relEntryPath, opts);
64
73
  copiedFiles.push(...subFiles);
65
74
  } else {
66
- await copyFile(srcEntryPath, targetDir, relEntryPath, opts);
67
- copiedFiles.push(relEntryPath);
75
+ const hash = await copyFile(srcEntryPath, targetDir, relEntryPath, opts);
76
+ copiedFiles.push({ path: relEntryPath, hash });
68
77
  }
69
78
  }
70
79
  return copiedFiles;
71
80
  }
72
81
 
73
82
  // src/modules/manifest.ts
74
- import { readFile, writeFile } from "fs/promises";
83
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
75
84
  import path2 from "path";
76
85
  var ManifestNotFoundError = class extends Error {
77
86
  constructor(manifestPath2) {
@@ -154,7 +163,7 @@ async function readManifest(targetDir) {
154
163
  const filePath = manifestPath(targetDir);
155
164
  let content;
156
165
  try {
157
- content = await readFile(filePath, "utf-8");
166
+ content = await readFile2(filePath, "utf-8");
158
167
  } catch (err) {
159
168
  if (err.code === "ENOENT") {
160
169
  throw new ManifestNotFoundError(filePath);
@@ -180,7 +189,7 @@ async function resolveSecretsScanExcludePrefixes(targetDir) {
180
189
  } catch {
181
190
  }
182
191
  try {
183
- const raw = await readFile(path2.join(targetDir, "src", "assets", ".arcane.json"), "utf-8");
192
+ const raw = await readFile2(path2.join(targetDir, "src", "assets", ".arcane.json"), "utf-8");
184
193
  const parsed = JSON.parse(raw);
185
194
  if (isRecord(parsed) && parsed["selfHosted"] === true && parsed["tracking_mode"] === "internal") {
186
195
  const prefixes = parsed["secretsScanExcludePrefixes"];
@@ -192,7 +201,7 @@ async function resolveSecretsScanExcludePrefixes(targetDir) {
192
201
  }
193
202
  async function writeManifest(targetDir, manifest) {
194
203
  const filePath = manifestPath(targetDir);
195
- await writeFile(filePath, JSON.stringify(manifest, null, 2), "utf-8");
204
+ await writeFile2(filePath, JSON.stringify(manifest, null, 2), "utf-8");
196
205
  }
197
206
  function addComponent(manifest, component) {
198
207
  return {
@@ -866,7 +875,7 @@ function printWarning(message) {
866
875
 
867
876
  // src/modules/agents.ts
868
877
  import { select, checkbox, Separator } from "@inquirer/prompts";
869
- import { mkdir as mkdir3, writeFile as writeFile4, copyFile as copyFile2 } from "fs/promises";
878
+ import { mkdir as mkdir3, writeFile as writeFile5, copyFile as copyFile2 } from "fs/promises";
870
879
  import { join as join3 } from "path";
871
880
  import { stringify } from "yaml";
872
881
 
@@ -1102,7 +1111,7 @@ async function applyNamingStrategy(strategy, roleIds) {
1102
1111
  }
1103
1112
 
1104
1113
  // src/modules/agent-loader.ts
1105
- import { readFile as readFile2, readdir as readdir2, access as access2 } from "fs/promises";
1114
+ import { readFile as readFile3, readdir as readdir2, access as access2 } from "fs/promises";
1106
1115
  import { join } from "path";
1107
1116
  import { parse } from "yaml";
1108
1117
 
@@ -1295,7 +1304,7 @@ async function loadAgentDefinition(agentsDir, id) {
1295
1304
  const filePath = join(agentsDir, `${id}.yaml`);
1296
1305
  let content;
1297
1306
  try {
1298
- content = await readFile2(filePath, "utf8");
1307
+ content = await readFile3(filePath, "utf8");
1299
1308
  } catch {
1300
1309
  throw new AgentDefinitionNotFoundError(id, agentsDir);
1301
1310
  }
@@ -1305,7 +1314,7 @@ async function loadRoster(targetDir) {
1305
1314
  const rosterPath = join(targetDir, ".arcane", "agents.yaml");
1306
1315
  let content;
1307
1316
  try {
1308
- content = await readFile2(rosterPath, "utf8");
1317
+ content = await readFile3(rosterPath, "utf8");
1309
1318
  } catch {
1310
1319
  throw new AgentRosterNotFoundError();
1311
1320
  }
@@ -1324,12 +1333,12 @@ function projectAgentsDir(targetDir) {
1324
1333
  }
1325
1334
 
1326
1335
  // src/modules/agent-generator.ts
1327
- import { writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
1336
+ import { writeFile as writeFile4, mkdir as mkdir2 } from "fs/promises";
1328
1337
  import { join as join2 } from "path";
1329
1338
  import { homedir } from "os";
1330
1339
 
1331
1340
  // src/modules/merger.ts
1332
- import { readFile as readFile3, writeFile as writeFile2, rm } from "fs/promises";
1341
+ import { readFile as readFile4, writeFile as writeFile3, rm } from "fs/promises";
1333
1342
  import path3 from "path";
1334
1343
  var MARKER_START = "<!-- arcane:start -->";
1335
1344
  var MARKER_END = "<!-- arcane:end -->";
@@ -1344,7 +1353,7 @@ async function mergeIntoFile(targetDir, relativePath, content, opts = {}) {
1344
1353
  const destPath = path3.resolve(targetDir, relativePath);
1345
1354
  let existing;
1346
1355
  try {
1347
- existing = await readFile3(destPath, "utf8");
1356
+ existing = await readFile4(destPath, "utf8");
1348
1357
  } catch (err) {
1349
1358
  const e = err;
1350
1359
  if (e.code === "ENOENT") {
@@ -1360,7 +1369,7 @@ ${content}
1360
1369
  ${MARKER_END}
1361
1370
  `;
1362
1371
  if (!opts.dryRun) {
1363
- await writeFile2(destPath, newContent, "utf8");
1372
+ await writeFile3(destPath, newContent, "utf8");
1364
1373
  }
1365
1374
  return true;
1366
1375
  }
@@ -1385,7 +1394,7 @@ ${MARKER_END}
1385
1394
  ${content}
1386
1395
  ${afterMarker}`;
1387
1396
  if (!opts.dryRun) {
1388
- await writeFile2(destPath, updated, "utf8");
1397
+ await writeFile3(destPath, updated, "utf8");
1389
1398
  }
1390
1399
  return true;
1391
1400
  }
@@ -1395,7 +1404,7 @@ ${content}
1395
1404
  ${MARKER_END}
1396
1405
  `;
1397
1406
  if (!opts.dryRun) {
1398
- await writeFile2(destPath, appended, "utf8");
1407
+ await writeFile3(destPath, appended, "utf8");
1399
1408
  }
1400
1409
  return true;
1401
1410
  }
@@ -1403,7 +1412,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1403
1412
  const destPath = path3.resolve(targetDir, relativePath);
1404
1413
  let existing;
1405
1414
  try {
1406
- existing = await readFile3(destPath, "utf8");
1415
+ existing = await readFile4(destPath, "utf8");
1407
1416
  } catch {
1408
1417
  return false;
1409
1418
  }
@@ -1417,7 +1426,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1417
1426
  if (stripped.length === 0) {
1418
1427
  await rm(destPath, { force: true });
1419
1428
  } else {
1420
- await writeFile2(destPath, stripped + "\n", "utf8");
1429
+ await writeFile3(destPath, stripped + "\n", "utf8");
1421
1430
  }
1422
1431
  }
1423
1432
  return true;
@@ -1571,17 +1580,17 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1571
1580
  try {
1572
1581
  if (!options.dryRun) {
1573
1582
  await mkdir2(workspaceDir, { recursive: true });
1574
- await writeFile3(
1583
+ await writeFile4(
1575
1584
  join2(workspaceDir, "IDENTITY.md"),
1576
1585
  renderIdentity(def, displayName),
1577
1586
  "utf8"
1578
1587
  );
1579
- await writeFile3(
1588
+ await writeFile4(
1580
1589
  join2(workspaceDir, "SOUL.md"),
1581
1590
  renderSoul(def, displayName),
1582
1591
  "utf8"
1583
1592
  );
1584
- await writeFile3(
1593
+ await writeFile4(
1585
1594
  join2(workspaceDir, "TOOLS.md"),
1586
1595
  renderTools(def, displayName),
1587
1596
  "utf8"
@@ -1610,7 +1619,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1610
1619
  const generatedDir = join2(targetDir, ".arcane", "generated");
1611
1620
  if (!options.dryRun) {
1612
1621
  await mkdir2(generatedDir, { recursive: true });
1613
- await writeFile3(
1622
+ await writeFile4(
1614
1623
  join2(generatedDir, "openclaw-roster.json"),
1615
1624
  JSON.stringify(patch, null, 2) + "\n",
1616
1625
  "utf8"
@@ -1632,7 +1641,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1632
1641
  const slug = displayName.toLowerCase().replace(/\s+/g, "-");
1633
1642
  const fileName = `${slug}.agent.md`;
1634
1643
  if (!options.dryRun) {
1635
- await writeFile3(
1644
+ await writeFile4(
1636
1645
  join2(agentsDir, fileName),
1637
1646
  renderCopilotAgent(def, displayName),
1638
1647
  "utf8"
@@ -1777,7 +1786,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
1777
1786
  const arcaneDir = join3(targetDir, ".arcane", "agents");
1778
1787
  if (!options.dryRun) {
1779
1788
  await mkdir3(arcaneDir, { recursive: true });
1780
- await writeFile4(
1789
+ await writeFile5(
1781
1790
  join3(targetDir, ".arcane", "agents.yaml"),
1782
1791
  stringify(roster),
1783
1792
  "utf8"
@@ -1921,7 +1930,7 @@ async function runAgentsList(targetDir) {
1921
1930
  }
1922
1931
 
1923
1932
  // src/modules/push-safety.ts
1924
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod, access as access3, rm as rm2, readdir as readdir3 } from "fs/promises";
1933
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile6, chmod, access as access3, rm as rm2, readdir as readdir3 } from "fs/promises";
1925
1934
  import { constants } from "fs";
1926
1935
  import { join as join4, resolve, dirname, basename } from "path";
1927
1936
 
@@ -2177,7 +2186,7 @@ function isArcaneHooksPath(root, value) {
2177
2186
  };
2178
2187
  return normalize(value) === normalize(ARCANE_HOOKS_DIR);
2179
2188
  }
2180
- var HOOK_BODY = `#!/bin/sh
2189
+ var PRE_PUSH_HOOK_BODY = `#!/bin/sh
2181
2190
  # Installed by Arcane because this repository's .arcane.json sets
2182
2191
  # push_policy: "blocked".
2183
2192
  #
@@ -2233,13 +2242,13 @@ async function repositoryRoot(cwd) {
2233
2242
  }
2234
2243
  return cwd;
2235
2244
  }
2236
- async function hookFilePath(cwd) {
2237
- return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, "pre-push");
2245
+ async function hookFilePath(cwd, hookName = "pre-push") {
2246
+ return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2238
2247
  }
2239
2248
  async function hooksPathValue(cwd) {
2240
2249
  return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2241
2250
  }
2242
- async function installPrePushHook(cwd) {
2251
+ async function installHook(cwd, hookName, body) {
2243
2252
  const root = await repositoryRoot(cwd);
2244
2253
  const existing = await readHooksPath(cwd);
2245
2254
  if (existing?.unreadable === true) return { status: "refused-unreadable-config" };
@@ -2256,19 +2265,27 @@ async function installPrePushHook(cwd) {
2256
2265
  return { status: "refused-default-hooks", hooks: displaced };
2257
2266
  }
2258
2267
  }
2259
- const hookPath = join4(root, ARCANE_HOOKS_DIR, "pre-push");
2260
- if (existing !== void 0 && await hookBodyMatches(cwd)) return { status: "already-ours" };
2268
+ const hookPath = join4(root, ARCANE_HOOKS_DIR, hookName);
2269
+ if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
2270
+ return { status: "already-ours" };
2271
+ }
2261
2272
  await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2262
- await writeFile5(hookPath, HOOK_BODY, "utf-8");
2273
+ await writeFile6(hookPath, body, "utf-8");
2263
2274
  await chmod(hookPath, 493);
2264
2275
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2265
2276
  return { status: "installed", path: hookPath };
2266
2277
  }
2267
- async function isHookEnforced(cwd) {
2278
+ async function installPrePushHook(cwd) {
2279
+ return installHook(cwd, "pre-push", PRE_PUSH_HOOK_BODY);
2280
+ }
2281
+ async function isHookInstalled(cwd, hookName, body) {
2268
2282
  const hooksPath = await readHooksPath(cwd);
2269
2283
  if (hooksPath === void 0 || hooksPath.unreadable === true) return false;
2270
2284
  if (!isArcaneHooksPath(await repositoryRoot(cwd), hooksPath.value)) return false;
2271
- return hookBodyMatches(cwd);
2285
+ return hookFileMatches(cwd, hookName, body);
2286
+ }
2287
+ async function isHookEnforced(cwd) {
2288
+ return isHookInstalled(cwd, "pre-push", PRE_PUSH_HOOK_BODY);
2272
2289
  }
2273
2290
  async function defaultHooks(cwd) {
2274
2291
  let hooksDir;
@@ -2284,11 +2301,11 @@ async function defaultHooks(cwd) {
2284
2301
  return [];
2285
2302
  }
2286
2303
  }
2287
- async function hookBodyMatches(cwd) {
2288
- const hookPath = await hookFilePath(cwd);
2304
+ async function hookFileMatches(cwd, hookName, body) {
2305
+ const hookPath = await hookFilePath(cwd, hookName);
2289
2306
  if (!await fileExists(hookPath)) return false;
2290
2307
  try {
2291
- if (await readFile4(hookPath, "utf-8") !== HOOK_BODY) return false;
2308
+ if (await readFile5(hookPath, "utf-8") !== body) return false;
2292
2309
  } catch {
2293
2310
  return false;
2294
2311
  }
@@ -2300,21 +2317,33 @@ async function hookBodyMatches(cwd) {
2300
2317
  return false;
2301
2318
  }
2302
2319
  }
2303
- async function removePrePushHook(cwd) {
2320
+ async function removeHook(cwd, hookName) {
2304
2321
  const existing = await readHooksPath(cwd);
2305
2322
  if (existing === void 0 || existing.unreadable === true) return;
2306
- if (!isArcaneHooksPath(await repositoryRoot(cwd), existing.value)) return;
2323
+ const root = await repositoryRoot(cwd);
2324
+ if (!isArcaneHooksPath(root, existing.value)) return;
2307
2325
  if (existing.scope !== "local" && existing.scope !== "worktree") return;
2308
- const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2309
2326
  try {
2310
- await runGit(cwd, ["config", scopeFlag, "--unset", "core.hooksPath"]);
2327
+ await rm2(await hookFilePath(cwd, hookName), { force: true });
2328
+ } catch {
2329
+ }
2330
+ const hooksDir = join4(root, ARCANE_HOOKS_DIR);
2331
+ let remaining = [];
2332
+ try {
2333
+ remaining = await readdir3(hooksDir);
2311
2334
  } catch {
2335
+ remaining = [];
2312
2336
  }
2337
+ if (remaining.length > 0) return;
2338
+ const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2313
2339
  try {
2314
- await rm2(await hookFilePath(cwd), { force: true });
2340
+ await runGit(cwd, ["config", scopeFlag, "--unset", "core.hooksPath"]);
2315
2341
  } catch {
2316
2342
  }
2317
2343
  }
2344
+ async function removePrePushHook(cwd) {
2345
+ return removeHook(cwd, "pre-push");
2346
+ }
2318
2347
  async function listRemotes(cwd) {
2319
2348
  try {
2320
2349
  const { stdout } = await runGit(cwd, ["remote"]);
@@ -2522,6 +2551,58 @@ function describeError(error) {
2522
2551
  return message.split("\n")[0].trim();
2523
2552
  }
2524
2553
 
2554
+ // src/modules/secrets-scan.ts
2555
+ var SECRETS_RULES = [
2556
+ { label: "api-key", pattern: /API[_-]KEY\s*[:=]\s*\S+/i },
2557
+ // Case-sensitive and requiring an all-caps/underscore identifier (SECRET,
2558
+ // MY_SECRET, API_SECRET_KEY...) -- the real config/env-var convention for a
2559
+ // credential-holding key. Widening this repository-wide (ARC-037 decision 3)
2560
+ // surfaced that the original case-insensitive form matched ANY lowercase
2561
+ // `secret`/`token` code identifier (e.g. `const token = value.trim();`),
2562
+ // which is overwhelmingly a parsed/resolved value, never a literal
2563
+ // credential. The `(?!\$?\{)` guard additionally excludes any
2564
+ // brace-delimited placeholder -- GitHub Actions/templating references
2565
+ // (`TOKEN: ${{ secrets.X }}`, a reference to a secret store, never the
2566
+ // secret itself) and this codebase's own documented `{UPPER_SNAKE}`
2567
+ // placeholder convention (denylist-scan.ts's `scanFile` already strips
2568
+ // those before matching for the sibling org-token scan; a bare single
2569
+ // brace, with no `scanFile`-style stripping in the copy-time call path,
2570
+ // needed the same exclusion here rather than only the double-brace case).
2571
+ // A minimum length of 8 further excludes short non-secret values like
2572
+ // `write` (`id-token: write`).
2573
+ { label: "generic-secret", pattern: /\b[A-Z0-9_]*SECRET[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
2574
+ { label: "generic-token", pattern: /\b[A-Z0-9_]*TOKEN[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
2575
+ { label: "bearer-token", pattern: /Bearer\s+[A-Za-z0-9._~+/-]{20,}/ },
2576
+ // real tokens are long
2577
+ { label: "openai-key", pattern: /sk-[A-Za-z0-9]{20,}/ },
2578
+ { label: "pem-key", pattern: /-----BEGIN [A-Z ]+-----/ },
2579
+ { label: "slack-token", pattern: /xox[bpars]-[A-Za-z0-9-]+/ },
2580
+ { label: "aws-access-key", pattern: /AKIA[0-9A-Z]{16}/ },
2581
+ { label: "github-pat", pattern: /ghp_[A-Za-z0-9]{36}/ }
2582
+ ];
2583
+ var SECRETS_PATTERNS = SECRETS_RULES.map((rule) => rule.pattern);
2584
+ var SECRETS_PRECOMMIT_HOOK_NAME = "pre-commit";
2585
+ var SECRETS_PRECOMMIT_HOOK_BODY = `#!/bin/sh
2586
+ # Installed by Arcane (ARC-037) to scan staged content for accidentally
2587
+ # committed credentials before they enter history.
2588
+ #
2589
+ # This is deliberately not tamper-proof: \`git commit --no-verify\` skips it
2590
+ # entirely. The real defense is the repository-wide scan Arcane's own build
2591
+ # runs in CI on every push -- this hook exists to catch a leak locally, for
2592
+ # free, before it ever reaches a commit.
2593
+ if ! command -v spell >/dev/null 2>&1; then
2594
+ echo "arcane: 'spell' not found on PATH -- skipping secrets scan (see: npm install -g arcane-cli)" >&2
2595
+ exit 0
2596
+ fi
2597
+ spell doctor --leaks
2598
+ `;
2599
+ async function installSecretsPrecommitHook(cwd) {
2600
+ return installHook(cwd, SECRETS_PRECOMMIT_HOOK_NAME, SECRETS_PRECOMMIT_HOOK_BODY);
2601
+ }
2602
+ async function removeSecretsPrecommitHook(cwd) {
2603
+ return removeHook(cwd, SECRETS_PRECOMMIT_HOOK_NAME);
2604
+ }
2605
+
2525
2606
  // src/commands/init.ts
2526
2607
  var VALID_PROFILES = ["full", "lite", "governance-only", "methodology", "docs"];
2527
2608
  var PROFILE_ICONS = {
@@ -2683,13 +2764,14 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2683
2764
  let fileCount = 0;
2684
2765
  for (const component of components) {
2685
2766
  const installedFiles = [];
2767
+ const fileHashes = {};
2686
2768
  for (const file of component.files) {
2687
2769
  const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
2688
2770
  if (options.dryRun) {
2689
2771
  printDryRun(`Would copy: ${file}`);
2690
2772
  } else {
2691
2773
  try {
2692
- await copyFile(srcPath, targetDir, file, { force: options.force });
2774
+ fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: options.force });
2693
2775
  } catch (err) {
2694
2776
  if (component.skipExisting && err instanceof Error && err.message.includes("already exists")) {
2695
2777
  continue;
@@ -2706,7 +2788,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2706
2788
  printDryRun(`Would copy directory: ${dir}/`);
2707
2789
  } else {
2708
2790
  const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: options.force });
2709
- installedFiles.push(...copied);
2791
+ for (const { path: copiedPath, hash } of copied) {
2792
+ installedFiles.push(copiedPath);
2793
+ fileHashes[copiedPath] = hash;
2794
+ }
2710
2795
  fileCount += copied.length;
2711
2796
  }
2712
2797
  }
@@ -2714,7 +2799,8 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2714
2799
  installedComponents.push({
2715
2800
  name: component.name,
2716
2801
  files: installedFiles,
2717
- installedVersion: packageVersion
2802
+ installedVersion: packageVersion,
2803
+ fileHashes
2718
2804
  });
2719
2805
  }
2720
2806
  }
@@ -2894,6 +2980,28 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2894
2980
  );
2895
2981
  }
2896
2982
  }
2983
+ try {
2984
+ const hook = await installSecretsPrecommitHook(targetDir);
2985
+ if (hook.status === "installed") {
2986
+ printInfo("Installed a pre-commit hook that scans staged content for leaked credentials.");
2987
+ } else if (hook.status === "refused-unreadable-config") {
2988
+ printWarning(
2989
+ "Did not install the secrets pre-commit hook: git could not report the current core.hooksPath (unreadable config, or a git too old for `--show-scope`)."
2990
+ );
2991
+ } else if (hook.status === "refused-default-hooks") {
2992
+ printWarning(
2993
+ `Did not install the secrets pre-commit 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.`
2994
+ );
2995
+ } else if (hook.status === "refused-foreign-hooks-path") {
2996
+ printWarning(
2997
+ `Did not install the secrets pre-commit hook: core.hooksPath is already "${hook.existing}" (${describeConfigScope(hook.scope)}), so another hook manager owns it.`
2998
+ );
2999
+ }
3000
+ } catch (err) {
3001
+ printWarning(
3002
+ `Could not install the secrets pre-commit hook: ${err instanceof Error ? err.message : String(err)}. Run \`spell doctor --leaks\` directly to scan on demand instead.`
3003
+ );
3004
+ }
2897
3005
  let agentsConfigured = false;
2898
3006
  if (hasAgents && !options.profile) {
2899
3007
  console.log();
@@ -2973,12 +3081,13 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2973
3081
  }
2974
3082
  let fileCount = 0;
2975
3083
  const installedFiles = [];
3084
+ const fileHashes = {};
2976
3085
  for (const file of component.files) {
2977
3086
  const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
2978
3087
  if (options.dryRun) {
2979
3088
  console.log(` [dry-run] Would copy: ${file}`);
2980
3089
  } else {
2981
- await copyFile(srcPath, targetDir, file, { force: options.force });
3090
+ fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: options.force });
2982
3091
  }
2983
3092
  installedFiles.push(file);
2984
3093
  fileCount++;
@@ -2989,7 +3098,10 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
2989
3098
  console.log(` [dry-run] Would copy directory: ${dir}/`);
2990
3099
  } else {
2991
3100
  const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: options.force });
2992
- installedFiles.push(...copied);
3101
+ for (const { path: copiedPath, hash } of copied) {
3102
+ installedFiles.push(copiedPath);
3103
+ fileHashes[copiedPath] = hash;
3104
+ }
2993
3105
  fileCount += copied.length;
2994
3106
  }
2995
3107
  }
@@ -3003,7 +3115,8 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3003
3115
  const newComponent = {
3004
3116
  name,
3005
3117
  files: installedFiles,
3006
- installedVersion: packageVersion
3118
+ installedVersion: packageVersion,
3119
+ fileHashes
3007
3120
  };
3008
3121
  const updated = addComponent(manifest, newComponent);
3009
3122
  await writeManifest(targetDir, updated);
@@ -3015,10 +3128,11 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3015
3128
 
3016
3129
  // src/commands/update.ts
3017
3130
  import { join as join8 } from "path";
3131
+ import { readFile as readFile6, writeFile as writeFile8, rm as rm3 } from "fs/promises";
3018
3132
 
3019
3133
  // src/modules/hub.ts
3020
3134
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
3021
- import { readdir as readdir4, writeFile as writeFile6 } from "fs/promises";
3135
+ import { readdir as readdir4, writeFile as writeFile7 } from "fs/promises";
3022
3136
  import { join as join7 } from "path";
3023
3137
  var MANIFEST_RETROFITS = [
3024
3138
  {
@@ -3198,12 +3312,154 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
3198
3312
  updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
3199
3313
  ventures
3200
3314
  };
3201
- await writeFile6(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3315
+ await writeFile7(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3202
3316
  console.log(
3203
3317
  ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
3204
3318
  );
3205
3319
  }
3206
3320
 
3321
+ // src/modules/merge3.ts
3322
+ var CONFLICT_START = "<<<<<<< yours (edited)";
3323
+ var CONFLICT_MID = "=======";
3324
+ var CONFLICT_END = ">>>>>>> incoming (vendor update)";
3325
+ function computeLcsDiff(base, other) {
3326
+ const n = base.length;
3327
+ const m = other.length;
3328
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
3329
+ for (let i2 = n - 1; i2 >= 0; i2--) {
3330
+ for (let j2 = m - 1; j2 >= 0; j2--) {
3331
+ dp[i2][j2] = base[i2] === other[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
3332
+ }
3333
+ }
3334
+ const ops = [];
3335
+ let i = 0;
3336
+ let j = 0;
3337
+ let pendingBaseStart = 0;
3338
+ let pendingOtherStart = 0;
3339
+ const flushReplace = (baseEnd, otherEnd) => {
3340
+ if (baseEnd > pendingBaseStart || otherEnd > pendingOtherStart) {
3341
+ ops.push({
3342
+ type: "replace",
3343
+ baseStart: pendingBaseStart,
3344
+ baseEnd,
3345
+ otherStart: pendingOtherStart,
3346
+ otherEnd
3347
+ });
3348
+ }
3349
+ };
3350
+ while (i < n && j < m) {
3351
+ if (base[i] === other[j]) {
3352
+ flushReplace(i, j);
3353
+ const eqBaseStart = i;
3354
+ const eqOtherStart = j;
3355
+ while (i < n && j < m && base[i] === other[j]) {
3356
+ i++;
3357
+ j++;
3358
+ }
3359
+ ops.push({ type: "equal", baseStart: eqBaseStart, baseEnd: i, otherStart: eqOtherStart, otherEnd: j });
3360
+ pendingBaseStart = i;
3361
+ pendingOtherStart = j;
3362
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
3363
+ i++;
3364
+ } else {
3365
+ j++;
3366
+ }
3367
+ }
3368
+ flushReplace(n, m);
3369
+ return ops;
3370
+ }
3371
+ function arraysEqual(a, b) {
3372
+ if (a.length !== b.length) return false;
3373
+ return a.every((line, idx) => line === b[idx]);
3374
+ }
3375
+ var MAX_LINES_FOR_LCS = 4e3;
3376
+ function splitLines(text) {
3377
+ return text.split("\n");
3378
+ }
3379
+ function merge3(base, ours, theirs) {
3380
+ const baseLines = splitLines(base);
3381
+ const oursLines = splitLines(ours);
3382
+ const theirsLines = splitLines(theirs);
3383
+ if (baseLines.length > MAX_LINES_FOR_LCS || oursLines.length > MAX_LINES_FOR_LCS || theirsLines.length > MAX_LINES_FOR_LCS) {
3384
+ return {
3385
+ content: [CONFLICT_START, ours, CONFLICT_MID, theirs, CONFLICT_END].join("\n"),
3386
+ hasConflict: true
3387
+ };
3388
+ }
3389
+ const oursOps = computeLcsDiff(baseLines, oursLines).filter((op) => op.type === "replace");
3390
+ const theirsOps = computeLcsDiff(baseLines, theirsLines).filter((op) => op.type === "replace");
3391
+ if (oursOps.length === 0 && theirsOps.length === 0) {
3392
+ return { content: base, hasConflict: false };
3393
+ }
3394
+ const intervals = [
3395
+ ...oursOps.map((op) => ({
3396
+ baseStart: op.baseStart,
3397
+ baseEnd: op.baseEnd,
3398
+ source: "ours",
3399
+ lines: oursLines.slice(op.otherStart, op.otherEnd)
3400
+ })),
3401
+ ...theirsOps.map((op) => ({
3402
+ baseStart: op.baseStart,
3403
+ baseEnd: op.baseEnd,
3404
+ source: "theirs",
3405
+ lines: theirsLines.slice(op.otherStart, op.otherEnd)
3406
+ }))
3407
+ ].sort((a, b) => a.baseStart - b.baseStart || a.baseEnd - b.baseEnd);
3408
+ const groups = [];
3409
+ let groupMaxEnd = -1;
3410
+ for (const iv of intervals) {
3411
+ const current = groups[groups.length - 1];
3412
+ if (current && iv.baseStart < groupMaxEnd) {
3413
+ current.push(iv);
3414
+ groupMaxEnd = Math.max(groupMaxEnd, iv.baseEnd);
3415
+ } else {
3416
+ groups.push([iv]);
3417
+ groupMaxEnd = iv.baseEnd;
3418
+ }
3419
+ }
3420
+ const resultLines = [];
3421
+ let hasConflict = false;
3422
+ let basePos = 0;
3423
+ for (const group of groups) {
3424
+ const groupStart = Math.min(...group.map((g) => g.baseStart));
3425
+ const groupEnd = Math.max(...group.map((g) => g.baseEnd));
3426
+ resultLines.push(...baseLines.slice(basePos, groupStart));
3427
+ const oursInGroup = group.filter((g) => g.source === "ours");
3428
+ const theirsInGroup = group.filter((g) => g.source === "theirs");
3429
+ if (oursInGroup.length > 0 && theirsInGroup.length === 0) {
3430
+ for (const g of oursInGroup) resultLines.push(...g.lines);
3431
+ } else if (theirsInGroup.length > 0 && oursInGroup.length === 0) {
3432
+ for (const g of theirsInGroup) resultLines.push(...g.lines);
3433
+ } else {
3434
+ const oursGroupLines = oursInGroup.flatMap((g) => g.lines);
3435
+ const theirsGroupLines = theirsInGroup.flatMap((g) => g.lines);
3436
+ if (arraysEqual(oursGroupLines, theirsGroupLines)) {
3437
+ resultLines.push(...oursGroupLines);
3438
+ } else {
3439
+ hasConflict = true;
3440
+ resultLines.push(CONFLICT_START, ...oursGroupLines, CONFLICT_MID, ...theirsGroupLines, CONFLICT_END);
3441
+ }
3442
+ }
3443
+ basePos = groupEnd;
3444
+ }
3445
+ resultLines.push(...baseLines.slice(basePos));
3446
+ return { content: resultLines.join("\n"), hasConflict };
3447
+ }
3448
+
3449
+ // src/modules/npm-registry.ts
3450
+ var NPM_PACKAGE_NAME = "arcane-cli";
3451
+ async function fetchPublishedFile(version, relativeAssetPath) {
3452
+ const normalized = relativeAssetPath.replace(/\\/g, "/").replace(/^\/+/, "");
3453
+ const url = `https://unpkg.com/${NPM_PACKAGE_NAME}@${version}/dist/assets/${normalized}`;
3454
+ try {
3455
+ const response = await fetch(url);
3456
+ if (!response.ok) return void 0;
3457
+ return await response.text();
3458
+ } catch {
3459
+ return void 0;
3460
+ }
3461
+ }
3462
+
3207
3463
  // src/commands/update.ts
3208
3464
  function migrateLegacyComponents(components) {
3209
3465
  const out = [];
@@ -3224,6 +3480,23 @@ function migrateLegacyComponents(components) {
3224
3480
  }
3225
3481
  return out;
3226
3482
  }
3483
+ async function resolveOrphan(targetDir, file, recordedHash, prune) {
3484
+ const filePath = join8(targetDir, file);
3485
+ if (!await fileExists(filePath)) return "not-found";
3486
+ if (!prune) return "reported";
3487
+ if (recordedHash === void 0) {
3488
+ console.log(` ! Orphaned, not pruned (no recorded hash to verify it's untouched): ${file}`);
3489
+ return "reported";
3490
+ }
3491
+ const currentHash = await hashFile(filePath);
3492
+ if (currentHash !== recordedHash) {
3493
+ console.log(` ! Orphaned but edited since install \u2014 not pruning: ${file}`);
3494
+ return "reported";
3495
+ }
3496
+ await rm3(filePath, { force: true });
3497
+ console.log(` Pruned orphaned file: ${file}`);
3498
+ return "pruned";
3499
+ }
3227
3500
  async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3228
3501
  let manifest;
3229
3502
  try {
@@ -3276,6 +3549,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3276
3549
  }
3277
3550
  let fileCount = 0;
3278
3551
  const updatedComponents = [];
3552
+ const conflictedFiles = [];
3553
+ const orphanReport = [];
3279
3554
  const componentsToUpdate = migrateLegacyComponents(manifest.components);
3280
3555
  for (const installed of componentsToUpdate) {
3281
3556
  let component;
@@ -3284,12 +3559,25 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3284
3559
  } catch (err) {
3285
3560
  if (err instanceof ComponentNotFoundError) {
3286
3561
  console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
3562
+ for (const file of installed.files) {
3563
+ const status = await resolveOrphan(
3564
+ targetDir,
3565
+ file,
3566
+ installed.fileHashes?.[file],
3567
+ Boolean(options.prune) && !options.dryRun
3568
+ );
3569
+ orphanReport.push({ file, status });
3570
+ }
3571
+ if (options.prune && !options.dryRun && installed.files.every((f) => orphanReport.find((o) => o.file === f)?.status !== "reported")) {
3572
+ continue;
3573
+ }
3287
3574
  updatedComponents.push(installed);
3288
3575
  continue;
3289
3576
  }
3290
3577
  throw err;
3291
3578
  }
3292
3579
  const updatedFiles = [];
3580
+ const fileHashes = {};
3293
3581
  for (const file of component.files) {
3294
3582
  const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
3295
3583
  const targetExists = await fileExists(join8(targetDir, file));
@@ -3306,7 +3594,38 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3306
3594
  console.log(` [dry-run] Would update: ${file}`);
3307
3595
  fileCount++;
3308
3596
  } else {
3309
- await copyFile(srcPath, targetDir, file, { force: true });
3597
+ const recordedHash = installed.fileHashes?.[file];
3598
+ let handled = false;
3599
+ if (recordedHash !== void 0 && targetExists) {
3600
+ const currentHash = await hashFile(join8(targetDir, file));
3601
+ if (currentHash !== recordedHash) {
3602
+ const oldVendorContent = await fetchPublishedFile(installed.installedVersion, file);
3603
+ if (oldVendorContent === void 0) {
3604
+ console.log(
3605
+ ` ! Could not fetch the previously published version of ${file} to merge your edits \u2014 left your version untouched. Update it manually if you want the latest.`
3606
+ );
3607
+ handled = true;
3608
+ } else {
3609
+ const [currentContent, newContent] = await Promise.all([
3610
+ readFile6(join8(targetDir, file), "utf-8"),
3611
+ readFile6(srcPath, "utf-8")
3612
+ ]);
3613
+ const result = merge3(oldVendorContent, currentContent, newContent);
3614
+ await writeFile8(join8(targetDir, file), result.content, "utf-8");
3615
+ fileHashes[file] = await hashFile(join8(targetDir, file));
3616
+ if (result.hasConflict) {
3617
+ conflictedFiles.push(file);
3618
+ console.log(` \u26A0 Merge conflict in ${file} \u2014 resolve the <<<<<<< markers before committing.`);
3619
+ } else {
3620
+ console.log(` Merged your edits into: ${file}`);
3621
+ }
3622
+ handled = true;
3623
+ }
3624
+ }
3625
+ }
3626
+ if (!handled) {
3627
+ fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: true });
3628
+ }
3310
3629
  fileCount++;
3311
3630
  }
3312
3631
  if (!preserveExisting || installed.files.includes(file)) {
@@ -3319,16 +3638,45 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3319
3638
  console.log(` [dry-run] Would update directory: ${dir}/`);
3320
3639
  } else {
3321
3640
  const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: true });
3322
- updatedFiles.push(...copied);
3641
+ for (const { path: copiedPath, hash } of copied) {
3642
+ updatedFiles.push(copiedPath);
3643
+ fileHashes[copiedPath] = hash;
3644
+ }
3323
3645
  fileCount += copied.length;
3324
3646
  }
3325
3647
  }
3648
+ for (const file of installed.files) {
3649
+ if (updatedFiles.includes(file)) continue;
3650
+ const status = await resolveOrphan(
3651
+ targetDir,
3652
+ file,
3653
+ installed.fileHashes?.[file],
3654
+ Boolean(options.prune) && !options.dryRun
3655
+ );
3656
+ orphanReport.push({ file, status });
3657
+ }
3326
3658
  updatedComponents.push({
3327
3659
  ...installed,
3328
3660
  files: updatedFiles,
3329
- installedVersion: packageVersion
3661
+ installedVersion: packageVersion,
3662
+ fileHashes
3330
3663
  });
3331
3664
  }
3665
+ const orphansToReport = orphanReport.filter((o) => o.status !== "not-found");
3666
+ if (orphansToReport.length > 0) {
3667
+ const verb = options.dryRun ? "[dry-run] Found" : "Found";
3668
+ console.log(
3669
+ `
3670
+ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update, no longer part of any current component):`
3671
+ );
3672
+ for (const { file, status } of orphansToReport) {
3673
+ const marker = status === "pruned" ? "pruned" : options.prune ? "kept (see reason above)" : "not removed";
3674
+ console.log(` ${file} \u2014 ${marker}`);
3675
+ }
3676
+ if (!options.prune) {
3677
+ console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
3678
+ }
3679
+ }
3332
3680
  if (options.dryRun) {
3333
3681
  console.log(
3334
3682
  `
@@ -3363,13 +3711,21 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3363
3711
  `
3364
3712
  \u2713 Updated ${fileCount} files.`
3365
3713
  );
3714
+ if (conflictedFiles.length > 0) {
3715
+ console.log(
3716
+ `
3717
+ \u26A0 ${conflictedFiles.length} file(s) have unresolved merge conflicts:
3718
+ ` + conflictedFiles.map((f) => ` ${f}`).join("\n") + `
3719
+ Search for "<<<<<<< yours" and resolve before committing.`
3720
+ );
3721
+ }
3366
3722
  if (manifest.role !== "hub" && updated.role === "hub") {
3367
3723
  await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
3368
3724
  }
3369
3725
  }
3370
3726
 
3371
3727
  // src/modules/version-check.ts
3372
- import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
3728
+ import { readFile as readFile7, writeFile as writeFile9, mkdir as mkdir5 } from "fs/promises";
3373
3729
  import { dirname as dirname2, join as join9 } from "path";
3374
3730
  import { homedir as homedir2 } from "os";
3375
3731
  import { createRequire } from "module";
@@ -3388,7 +3744,7 @@ var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
3388
3744
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
3389
3745
  async function readCache(cachePath) {
3390
3746
  try {
3391
- const content = await readFile5(cachePath, "utf-8");
3747
+ const content = await readFile7(cachePath, "utf-8");
3392
3748
  const parsed = JSON.parse(content);
3393
3749
  if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
3394
3750
  return null;
@@ -3401,7 +3757,7 @@ async function readCache(cachePath) {
3401
3757
  async function writeCache(cachePath, latest) {
3402
3758
  await mkdir5(dirname2(cachePath), { recursive: true });
3403
3759
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
3404
- await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3760
+ await writeFile9(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3405
3761
  }
3406
3762
  async function getLatestVersion(feedUrl) {
3407
3763
  const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
@@ -3560,7 +3916,7 @@ async function runStatus(targetDir, packageVersion) {
3560
3916
  }
3561
3917
 
3562
3918
  // src/commands/uninstall.ts
3563
- import { rm as rm3 } from "fs/promises";
3919
+ import { rm as rm4 } from "fs/promises";
3564
3920
  import { join as join10 } from "path";
3565
3921
  import { confirm as confirm3 } from "@inquirer/prompts";
3566
3922
  async function runUninstall(options, targetDir) {
@@ -3626,22 +3982,22 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3626
3982
  for (const file of component.files) {
3627
3983
  const filePath = join10(targetDir, file);
3628
3984
  try {
3629
- await rm3(filePath, { force: true });
3985
+ await rm4(filePath, { force: true });
3630
3986
  removed++;
3631
3987
  } catch {
3632
3988
  }
3633
3989
  }
3634
3990
  }
3635
- await rm3(join10(targetDir, ".arcane.json"), { force: true });
3991
+ await rm4(join10(targetDir, ".arcane.json"), { force: true });
3636
3992
  const agentDirs = [
3637
3993
  ".github/agents",
3638
3994
  ".arcane/agents",
3639
3995
  ".arcane/generated"
3640
3996
  ];
3641
3997
  for (const dir of agentDirs) {
3642
- await rm3(join10(targetDir, dir), { recursive: true, force: true });
3998
+ await rm4(join10(targetDir, dir), { recursive: true, force: true });
3643
3999
  }
3644
- await rm3(join10(targetDir, ".arcane/agents.yaml"), { force: true });
4000
+ await rm4(join10(targetDir, ".arcane/agents.yaml"), { force: true });
3645
4001
  const markerFiles = [
3646
4002
  "CLAUDE.md",
3647
4003
  "AGENTS.md",
@@ -3650,12 +4006,16 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3650
4006
  for (const file of markerFiles) {
3651
4007
  await stripMarkerSection(targetDir, file, { dryRun: false });
3652
4008
  }
4009
+ try {
4010
+ await removeSecretsPrecommitHook(targetDir);
4011
+ } catch {
4012
+ }
3653
4013
  console.log(`
3654
4014
  \u2713 Uninstalled \u2014 ${removed} files removed.`);
3655
4015
  }
3656
4016
 
3657
4017
  // src/commands/doctor.ts
3658
- import { access as access4, readFile as readFile7, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
4018
+ import { access as access4, readFile as readFile9, mkdir as mkdir6, copyFile as fsCopyFile } from "fs/promises";
3659
4019
  import { join as join12, dirname as dirname3 } from "path";
3660
4020
  import { execFile as execFile3 } from "child_process";
3661
4021
  import { promisify as promisify2 } from "util";
@@ -3726,7 +4086,7 @@ function evaluateIncidentGate(records) {
3726
4086
  }
3727
4087
 
3728
4088
  // src/modules/denylist-scan.ts
3729
- import { readFile as readFile6, readdir as readdir5 } from "fs/promises";
4089
+ import { readFile as readFile8, readdir as readdir5 } from "fs/promises";
3730
4090
  import { join as join11, relative } from "path";
3731
4091
  function escapeRegExp(value) {
3732
4092
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -3744,7 +4104,7 @@ async function scanFile(absolutePath, displayPath, rules) {
3744
4104
  const findings = [];
3745
4105
  let content;
3746
4106
  try {
3747
- content = await readFile6(absolutePath, "utf8");
4107
+ content = await readFile8(absolutePath, "utf8");
3748
4108
  } catch {
3749
4109
  return findings;
3750
4110
  }
@@ -3799,37 +4159,6 @@ async function scanRepository(repoRoot, rules, extensions, excludePrefixes = [])
3799
4159
  return findings;
3800
4160
  }
3801
4161
 
3802
- // src/modules/secrets-scan.ts
3803
- var SECRETS_RULES = [
3804
- { label: "api-key", pattern: /API[_-]KEY\s*[:=]\s*\S+/i },
3805
- // Case-sensitive and requiring an all-caps/underscore identifier (SECRET,
3806
- // MY_SECRET, API_SECRET_KEY...) -- the real config/env-var convention for a
3807
- // credential-holding key. Widening this repository-wide (ARC-037 decision 3)
3808
- // surfaced that the original case-insensitive form matched ANY lowercase
3809
- // `secret`/`token` code identifier (e.g. `const token = value.trim();`),
3810
- // which is overwhelmingly a parsed/resolved value, never a literal
3811
- // credential. The `(?!\$?\{)` guard additionally excludes any
3812
- // brace-delimited placeholder -- GitHub Actions/templating references
3813
- // (`TOKEN: ${{ secrets.X }}`, a reference to a secret store, never the
3814
- // secret itself) and this codebase's own documented `{UPPER_SNAKE}`
3815
- // placeholder convention (denylist-scan.ts's `scanFile` already strips
3816
- // those before matching for the sibling org-token scan; a bare single
3817
- // brace, with no `scanFile`-style stripping in the copy-time call path,
3818
- // needed the same exclusion here rather than only the double-brace case).
3819
- // A minimum length of 8 further excludes short non-secret values like
3820
- // `write` (`id-token: write`).
3821
- { label: "generic-secret", pattern: /\b[A-Z0-9_]*SECRET[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
3822
- { label: "generic-token", pattern: /\b[A-Z0-9_]*TOKEN[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
3823
- { label: "bearer-token", pattern: /Bearer\s+[A-Za-z0-9._~+/-]{20,}/ },
3824
- // real tokens are long
3825
- { label: "openai-key", pattern: /sk-[A-Za-z0-9]{20,}/ },
3826
- { label: "pem-key", pattern: /-----BEGIN [A-Z ]+-----/ },
3827
- { label: "slack-token", pattern: /xox[bpars]-[A-Za-z0-9-]+/ },
3828
- { label: "aws-access-key", pattern: /AKIA[0-9A-Z]{16}/ },
3829
- { label: "github-pat", pattern: /ghp_[A-Za-z0-9]{36}/ }
3830
- ];
3831
- var SECRETS_PATTERNS = SECRETS_RULES.map((rule) => rule.pattern);
3832
-
3833
4162
  // src/modules/platform-policy.ts
3834
4163
  import { execFile as execFile2 } from "child_process";
3835
4164
  import { promisify } from "util";
@@ -4062,7 +4391,7 @@ async function checkArcaneManifest(targetDir) {
4062
4391
  } catch {
4063
4392
  const selfHostedManifestPath = join12(targetDir, "src", "assets", ".arcane.json");
4064
4393
  try {
4065
- const raw = await readFile7(selfHostedManifestPath, "utf8");
4394
+ const raw = await readFile9(selfHostedManifestPath, "utf8");
4066
4395
  const manifest = JSON.parse(raw);
4067
4396
  if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
4068
4397
  return {
@@ -4081,7 +4410,7 @@ async function checkArcaneManifest(targetDir) {
4081
4410
  };
4082
4411
  }
4083
4412
  try {
4084
- const raw = await readFile7(manifestPath2, "utf8");
4413
+ const raw = await readFile9(manifestPath2, "utf8");
4085
4414
  const manifest = JSON.parse(raw);
4086
4415
  if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
4087
4416
  return {
@@ -4190,7 +4519,7 @@ async function fixSessionContinuity(targetDir, assetsDir) {
4190
4519
  } catch {
4191
4520
  const srcPath = join12(assetsDir, file);
4192
4521
  await mkdir6(dirname3(destPath), { recursive: true });
4193
- await fsCopyFile2(srcPath, destPath);
4522
+ await fsCopyFile(srcPath, destPath);
4194
4523
  created.push(file);
4195
4524
  }
4196
4525
  }
@@ -4252,7 +4581,7 @@ async function checkMcpConfig(targetDir) {
4252
4581
  const filePath = join12(targetDir, ".mcp.json");
4253
4582
  let raw;
4254
4583
  try {
4255
- raw = await readFile7(filePath, "utf8");
4584
+ raw = await readFile9(filePath, "utf8");
4256
4585
  } catch {
4257
4586
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
4258
4587
  }
@@ -4308,7 +4637,7 @@ async function checkDelegations(targetDir) {
4308
4637
  const filePath = join12(targetDir, ".arcane", "delegations.json");
4309
4638
  let raw;
4310
4639
  try {
4311
- raw = await readFile7(filePath, "utf8");
4640
+ raw = await readFile9(filePath, "utf8");
4312
4641
  } catch {
4313
4642
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
4314
4643
  }
@@ -4456,13 +4785,13 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4456
4785
  }
4457
4786
 
4458
4787
  // src/modules/ward.ts
4459
- import { readdir as readdir6, readFile as readFile8 } from "fs/promises";
4788
+ import { readdir as readdir6, readFile as readFile10 } from "fs/promises";
4460
4789
  import { join as join13, relative as relative2 } from "path";
4461
4790
  async function scanFileAllRules(absolutePath, displayPath, rules) {
4462
4791
  const findings = [];
4463
4792
  let content;
4464
4793
  try {
4465
- content = await readFile8(absolutePath, "utf8");
4794
+ content = await readFile10(absolutePath, "utf8");
4466
4795
  } catch {
4467
4796
  return findings;
4468
4797
  }
@@ -4557,7 +4886,7 @@ async function runWard(repoRoot, options = {}) {
4557
4886
  const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
4558
4887
  if (findings2.length > 0) {
4559
4888
  try {
4560
- fileLines.set(displayPath, (await readFile8(absolutePath, "utf8")).split("\n"));
4889
+ fileLines.set(displayPath, (await readFile10(absolutePath, "utf8")).split("\n"));
4561
4890
  } catch {
4562
4891
  }
4563
4892
  }
@@ -4766,9 +5095,12 @@ program.command("add <component>").description("Add a component to an existing A
4766
5095
  );
4767
5096
  }
4768
5097
  );
4769
- program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").action(async (opts) => {
5098
+ program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
5099
+ "--prune",
5100
+ "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"
5101
+ ).action(async (opts) => {
4770
5102
  await runUpdate(
4771
- { dryRun: opts.dryRun },
5103
+ { dryRun: opts.dryRun, prune: opts.prune },
4772
5104
  process.cwd(),
4773
5105
  ASSETS_DIR,
4774
5106
  pkg.version