rulesync 16.20.0 → 16.21.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.
@@ -1,8 +1,9 @@
1
1
  import { ZodError } from "zod";
2
2
  import { meta, minLength, nonnegative, optional, refine, z } from "zod/mini";
3
- import { chmod, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
3
+ import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
4
4
  import path, { basename, dirname, extname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
5
5
  import { applyEdits, findNodeAtLocation, getNodeValue, modify, parse, parseTree, printParseErrorCode } from "jsonc-parser";
6
+ import { constants } from "node:fs";
6
7
  import os from "node:os";
7
8
  import { intersection, kebabCase, uniq } from "es-toolkit";
8
9
  import { globbySync, isGitIgnoredSync } from "globby";
@@ -11,7 +12,6 @@ import { format, isDeepStrictEqual } from "node:util";
11
12
  import matter from "gray-matter";
12
13
  import { YAMLException, dump, load } from "js-yaml";
13
14
  import { omit } from "es-toolkit/object";
14
- import { constants } from "node:fs";
15
15
  import { createHash } from "node:crypto";
16
16
  import * as smolToml from "smol-toml";
17
17
  import { parse as parse$1, stringify } from "smol-toml";
@@ -38,6 +38,26 @@ function stripControlCharacters(text) {
38
38
  return text.replace(CONTROL_CHARACTERS_PATTERN, "");
39
39
  }
40
40
  /**
41
+ * Strips the control characters from `text` and then JSON-quotes it, which is
42
+ * the form a remote-derived name — a fetched path, a directory name read off
43
+ * the disk, a branch name chosen by a remote repository — takes in a log line.
44
+ *
45
+ * The two steps do different jobs, and neither is enough on its own. The
46
+ * quoting delimits the untrusted text, so a reader can tell where the name
47
+ * ends and the diagnostic resumes, and it escapes the quotes and backslashes
48
+ * that would blur that edge. But `JSON.stringify` escapes the C0 controls and
49
+ * nothing else: the C1 range, the 8-bit CSI introducer among it, and the bidi
50
+ * overrides pass through it intact, and any one of them reaches the terminal
51
+ * with its power to forge a line or reorder one. The strip is what takes those
52
+ * out. It runs first so that the quoting sees only printable text and the line
53
+ * carries no escaped control characters the reader has to decode — what could
54
+ * do harm is gone rather than spelled out — and so that the rationale for the
55
+ * order lives here, once, rather than at every call site.
56
+ */
57
+ function quoteForLog(text) {
58
+ return JSON.stringify(stripControlCharacters(text));
59
+ }
60
+ /**
41
61
  * Removes every control character from `text` except the line feed, so a
42
62
  * message written to be read over several lines still is.
43
63
  *
@@ -903,9 +923,9 @@ function toPosixPath(p) {
903
923
  return p.replace(/\\/g, "/");
904
924
  }
905
925
  function checkPathTraversal({ relativePath, intendedRootDir }) {
906
- if (relativePath.split(/[/\\]/).includes("..")) throw new Error(`Path traversal detected: ${JSON.stringify(stripControlCharacters(relativePath))}`);
926
+ if (relativePath.split(/[/\\]/).includes("..")) throw new Error(`Path traversal detected: ${quoteForLog(relativePath)}`);
907
927
  const resolved = resolve(intendedRootDir, relativePath);
908
- if (relative(intendedRootDir, resolved).startsWith("..") || resolve(resolved) !== resolved) throw new Error(`Path traversal detected: ${JSON.stringify(stripControlCharacters(relativePath))}`);
928
+ if (relative(intendedRootDir, resolved).startsWith("..") || resolve(resolved) !== resolved) throw new Error(`Path traversal detected: ${quoteForLog(relativePath)}`);
909
929
  }
910
930
  /**
911
931
  * Resolves a path relative to a base directory, handling both absolute and relative paths
@@ -965,24 +985,60 @@ async function writeFileContent(filepath, content) {
965
985
  * Apply a POSIX mode to an existing file. Windows has no executable bit and
966
986
  * `chmod` there only toggles the read-only flag, so the call is skipped rather
967
987
  * than writing a mode the platform cannot honor.
988
+ *
989
+ * The mode goes through a handle opened with `O_NOFOLLOW`, so a symbolic link
990
+ * standing where the file should be is left alone rather than having the mode
991
+ * land on whatever it points at -- possibly outside the output tree -- and the
992
+ * file whose mode changes is the very one that was opened, with no path to
993
+ * swap between the check and the `chmod`. The write side refuses to read
994
+ * through links; the mode side refuses to write through them. A link is
995
+ * skipped silently, like Windows; a file that does not exist still throws.
968
996
  */
969
997
  async function applyFileMode(filepath, mode) {
970
998
  if (process.platform === "win32") return;
971
- await chmod(filepath, mode);
999
+ const fileHandle = await openNotFollowingLinks(filepath);
1000
+ if (fileHandle === void 0) return;
1001
+ try {
1002
+ await fileHandle.chmod(mode);
1003
+ } finally {
1004
+ await fileHandle.close();
1005
+ }
972
1006
  }
973
1007
  /**
974
1008
  * Restore an executable bit that went missing (interrupted run, a copy that
975
1009
  * dropped the mode). A file whose mode is merely stricter than `mode` — the
976
- * user chose 0700 over 0755 — is left alone.
1010
+ * user chose 0700 over 0755 — is left alone, and so is a symbolic link or a
1011
+ * file that cannot be opened: this repairs, it never creates.
977
1012
  */
978
1013
  async function restoreMissingExecutableBit(filepath, mode) {
979
1014
  if (process.platform === "win32") return;
1015
+ let fileHandle;
980
1016
  try {
981
- if (((await stat(filepath)).mode & 73) !== 0) return;
1017
+ fileHandle = await openNotFollowingLinks(filepath);
982
1018
  } catch {
983
1019
  return;
984
1020
  }
985
- await chmod(filepath, mode);
1021
+ if (fileHandle === void 0) return;
1022
+ try {
1023
+ if (((await fileHandle.stat()).mode & 73) !== 0) return;
1024
+ await fileHandle.chmod(mode);
1025
+ } finally {
1026
+ await fileHandle.close();
1027
+ }
1028
+ }
1029
+ /**
1030
+ * Open a file for its mode without following a symbolic link at the path.
1031
+ * Returns `undefined` when the path is a link (`O_NOFOLLOW` fails with
1032
+ * `ELOOP`, or `EMLINK` on some BSDs); any other failure is the caller's.
1033
+ */
1034
+ async function openNotFollowingLinks(filepath) {
1035
+ try {
1036
+ return await open(filepath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
1037
+ } catch (error) {
1038
+ const code = error.code;
1039
+ if (code === "ELOOP" || code === "EMLINK") return;
1040
+ throw error;
1041
+ }
986
1042
  }
987
1043
  async function writeFileBuffer(filepath, buffer) {
988
1044
  await ensureDir(dirname(filepath));
@@ -1193,6 +1249,19 @@ function posixRelativePathEscapesRoot(relativePath) {
1193
1249
  return relativePath === ".." || relativePath.startsWith("../") || posix.isAbsolute(relativePath);
1194
1250
  }
1195
1251
  /**
1252
+ * Whether the file `targetPath` really denotes sits outside `rootPath`. Unlike
1253
+ * `pathEscapesRoot`, which reads a path as it is spelled, this resolves every link
1254
+ * on both sides first, so a name that sits inside the root but is a link pointing
1255
+ * out of it is reported as the escape it is. Both sides fall back to their literal
1256
+ * path when they cannot be resolved, which reports an escape rather than hiding one.
1257
+ */
1258
+ async function resolvedPathEscapesRoot({ rootPath, targetPath }) {
1259
+ return posixRelativePathEscapesRoot(await resolvedRelativePath({
1260
+ rootPath,
1261
+ targetPath
1262
+ }));
1263
+ }
1264
+ /**
1196
1265
  * How many trailing segments `filePath` and `identity` have in common.
1197
1266
  *
1198
1267
  * A path that walked through no link at all shares all of its own segments with
@@ -1202,9 +1271,17 @@ function posixRelativePathEscapesRoot(relativePath) {
1202
1271
  * what lets the comparison hold for a path that is not itself resolved: a glob
1203
1272
  * rooted at a directory that is a link of its own gives every candidate the
1204
1273
  * same unresolved prefix, and only the segments below it decide.
1274
+ *
1275
+ * `filePath` is spelled the way the platform reported it and `identity` is
1276
+ * posix-separated, so the two are split on both separators: on Windows a
1277
+ * backslash-separated path then shares its segments with the posix identity
1278
+ * without being rewritten first, and on a posix platform a backslash inside a
1279
+ * name is split the same way on both sides, which keeps the count symmetric.
1280
+ *
1281
+ * Exported for its tests, which need a Windows-style spelling on any platform.
1205
1282
  */
1206
1283
  function sharedTrailingSegments(filePath, identity) {
1207
- const left = splitPathSegments(nativePathToPosix(filePath));
1284
+ const left = splitPathSegments(filePath);
1208
1285
  const right = splitPathSegments(identity);
1209
1286
  let shared = 0;
1210
1287
  while (shared < left.length && shared < right.length && left[left.length - 1 - shared] === right[right.length - 1 - shared]) shared++;
@@ -1232,7 +1309,7 @@ function chooseRepresentative(candidates, identity) {
1232
1309
  });
1233
1310
  }
1234
1311
  async function findFilesByGlobs(globs, options = {}) {
1235
- const { type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
1312
+ const { cwd, type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
1236
1313
  const globbyOptions = type === "file" ? {
1237
1314
  onlyFiles: true,
1238
1315
  onlyDirectories: false
@@ -1246,6 +1323,7 @@ async function findFilesByGlobs(globs, options = {}) {
1246
1323
  const normalizedGlobs = Array.isArray(globs) ? globs.map((g) => g.replaceAll("\\", "/")) : globs.replaceAll("\\", "/");
1247
1324
  const results = globbySync(normalizedGlobs, {
1248
1325
  absolute: true,
1326
+ cwd,
1249
1327
  followSymbolicLinks,
1250
1328
  dot,
1251
1329
  ...ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {},
@@ -1318,14 +1396,16 @@ async function listEntryNames(params) {
1318
1396
  */
1319
1397
  async function dedupeNamesByFileIdentity(params) {
1320
1398
  const { dirPath, entries } = params;
1321
- const identities = await mapWithConcurrency({
1399
+ const identifiedEntries = await mapWithConcurrency({
1322
1400
  items: entries,
1323
1401
  limit: ENTRY_CLASSIFY_CONCURRENCY,
1324
- mapper: async (entry) => await realFileIdentity(join(dirPath, entry.name))
1402
+ mapper: async (entry) => ({
1403
+ entry,
1404
+ identity: await realFileIdentity(join(dirPath, entry.name))
1405
+ })
1325
1406
  });
1326
1407
  const entriesByIdentity = /* @__PURE__ */ new Map();
1327
- for (const [index, entry] of entries.entries()) {
1328
- const identity = identities[index] ?? nativePathToPosix(join(dirPath, entry.name));
1408
+ for (const { entry, identity } of identifiedEntries) {
1329
1409
  const group = entriesByIdentity.get(identity);
1330
1410
  if (group === void 0) entriesByIdentity.set(identity, [entry]);
1331
1411
  else group.push(entry);
@@ -1624,43 +1704,76 @@ var CLIError = class extends Error {
1624
1704
  };
1625
1705
  //#endregion
1626
1706
  //#region src/utils/warned-once.ts
1707
+ function createRunWarningState() {
1708
+ return {
1709
+ messages: /* @__PURE__ */ new Set(),
1710
+ carriedFilesIncomplete: false
1711
+ };
1712
+ }
1627
1713
  /**
1628
- * The messages a once-per-run warning has already emitted in this process.
1714
+ * The state of a run that opened no scope of its own.
1629
1715
  * This lives in its own module, importing nothing of rulesync's, so the vitest
1630
1716
  * setup file can clear it between tests without pulling `logger.js` into every
1631
1717
  * test's module graph (which would defeat the module mocks some of those tests
1632
1718
  * install).
1633
1719
  */
1634
- const processWideMessages = /* @__PURE__ */ new Set();
1720
+ const processWideState = createRunWarningState();
1635
1721
  /**
1636
- * The set an operation that opened its own scope uses instead.
1722
+ * The state an operation that opened its own scope uses instead.
1637
1723
  *
1638
1724
  * The MCP server does not serialize requests, so two runs can be in flight at
1639
1725
  * once. Sharing one set between them would let the first run spend the token
1640
1726
  * for a message and leave the second one's result silent about a diagnostic
1641
1727
  * that applies to it just as much. A scope gives each run its own bookkeeping.
1642
1728
  */
1643
- const scopedMessages = new AsyncLocalStorage();
1644
- function currentMessages() {
1645
- return scopedMessages.getStore() ?? processWideMessages;
1729
+ const scopedState = new AsyncLocalStorage();
1730
+ function currentState() {
1731
+ return scopedState.getStore() ?? processWideState;
1646
1732
  }
1647
1733
  /** Whether `message` has not been emitted yet; records it when it has not. */
1648
1734
  function claimWarnOnce(message) {
1649
- const messages = currentMessages();
1735
+ const { messages } = currentState();
1650
1736
  if (messages.has(message)) return false;
1651
1737
  messages.add(message);
1652
1738
  return true;
1653
1739
  }
1654
- /** Forget which warnings were already emitted, so the next run starts silent. */
1655
- function resetWarnedOnceMessages() {
1656
- currentMessages().clear();
1740
+ /**
1741
+ * Record that this run could not read everything a directory carries -- a file
1742
+ * it could not open, a walk that hit one of its bounds, a subtree it was denied.
1743
+ *
1744
+ * Kept here, beside the once-per-run messages, because it shares their lifetime
1745
+ * exactly: it is set at the moment such a shortfall is warned about, and cleared
1746
+ * when the next run resets its warnings.
1747
+ *
1748
+ * Deliberate refusals are not shortfalls. A hidden entry, a credential-shaped
1749
+ * name, a link into a pseudo-filesystem: those are files Rulesync never carries,
1750
+ * on every run, and a run that leaves them out has read its source in full.
1751
+ */
1752
+ function recordIncompleteCarriedFiles() {
1753
+ currentState().carriedFilesIncomplete = true;
1754
+ }
1755
+ /**
1756
+ * Whether {@link recordIncompleteCarriedFiles} fired in this run.
1757
+ *
1758
+ * A caller that deletes what a run did not write has to ask: a run holding an
1759
+ * incomplete picture of its source cannot tell a stale file from one whose
1760
+ * source it merely failed to read.
1761
+ */
1762
+ function hasIncompleteCarriedFiles() {
1763
+ return currentState().carriedFilesIncomplete;
1764
+ }
1765
+ /** Forget what was already reported, so the next run starts silent. */
1766
+ function resetRunWarningState() {
1767
+ const state = currentState();
1768
+ state.messages.clear();
1769
+ state.carriedFilesIncomplete = false;
1657
1770
  }
1658
1771
  /**
1659
1772
  * Run `operation` with its own once-per-run bookkeeping, so a concurrent run
1660
1773
  * neither spends its tokens nor clears its record.
1661
1774
  */
1662
1775
  async function withWarnOnceScope(operation) {
1663
- return await scopedMessages.run(/* @__PURE__ */ new Set(), operation);
1776
+ return await scopedState.run(createRunWarningState(), operation);
1664
1777
  }
1665
1778
  //#endregion
1666
1779
  //#region src/utils/logger.ts
@@ -2034,6 +2147,148 @@ var WarningCollectingLogger = class extends ConsoleLogger {
2034
2147
  }
2035
2148
  };
2036
2149
  //#endregion
2150
+ //#region src/types/language.ts
2151
+ /**
2152
+ * Response languages the root `language` key of `rulesync.jsonc` accepts.
2153
+ *
2154
+ * BCP 47-style codes: a bare ISO 639-1 code where one language name is
2155
+ * unambiguous, and a region-qualified code where the written form differs by
2156
+ * region (`zh-CN` / `zh-TW`, `pt-BR`). Widening the list is a non-breaking
2157
+ * change; respelling an existing code is not, so the code style is fixed here.
2158
+ */
2159
+ const LANGUAGE_CODES = [
2160
+ "en",
2161
+ "ja",
2162
+ "zh-CN",
2163
+ "zh-TW",
2164
+ "ko",
2165
+ "fr",
2166
+ "de",
2167
+ "es",
2168
+ "pt-BR",
2169
+ "ru"
2170
+ ];
2171
+ const LanguageSchema = z.enum(LANGUAGE_CODES);
2172
+ const LANGUAGE_DISPLAY = {
2173
+ en: {
2174
+ name: "English",
2175
+ claudecode: "english"
2176
+ },
2177
+ ja: {
2178
+ name: "Japanese",
2179
+ claudecode: "japanese"
2180
+ },
2181
+ "zh-CN": {
2182
+ name: "Simplified Chinese",
2183
+ claudecode: "simplified chinese"
2184
+ },
2185
+ "zh-TW": {
2186
+ name: "Traditional Chinese",
2187
+ claudecode: "traditional chinese"
2188
+ },
2189
+ ko: {
2190
+ name: "Korean",
2191
+ claudecode: "korean"
2192
+ },
2193
+ fr: {
2194
+ name: "French",
2195
+ claudecode: "french"
2196
+ },
2197
+ de: {
2198
+ name: "German",
2199
+ claudecode: "german"
2200
+ },
2201
+ es: {
2202
+ name: "Spanish",
2203
+ claudecode: "spanish"
2204
+ },
2205
+ "pt-BR": {
2206
+ name: "Brazilian Portuguese",
2207
+ claudecode: "brazilian portuguese"
2208
+ },
2209
+ ru: {
2210
+ name: "Russian",
2211
+ claudecode: "russian"
2212
+ }
2213
+ };
2214
+ /** English display name of a language, as used in the appended prompt. */
2215
+ function getLanguageName(language) {
2216
+ return LANGUAGE_DISPLAY[language].name;
2217
+ }
2218
+ /** The value Claude Code's `language` settings key expects for a language. */
2219
+ function getClaudecodeLanguageValue(language) {
2220
+ return LANGUAGE_DISPLAY[language].claudecode;
2221
+ }
2222
+ /**
2223
+ * The one sentence rulesync appends to generated root rule files when
2224
+ * `language` is set. Every supported language — `en` included — emits it, so
2225
+ * the instruction is explicit rather than implied by the absence of a block.
2226
+ */
2227
+ function buildLanguageInstruction(language) {
2228
+ return `You must always answer in ${getLanguageName(language)}. On the other hand, reasoning (thinking) should be in English to improve token efficiency.`;
2229
+ }
2230
+ /**
2231
+ * Separator that opens the appended block. A thematic break makes the
2232
+ * concatenation visible, so a reader can tell the sentence was appended by
2233
+ * rulesync rather than authored as part of the rules.
2234
+ */
2235
+ const LANGUAGE_BLOCK_SEPARATOR = "---";
2236
+ /**
2237
+ * Append the language block to a rule body: a blank line, the separator, a
2238
+ * blank line, then the instruction. A body that is empty (or whitespace only)
2239
+ * gets the instruction alone, because a file that starts with `---` reads as
2240
+ * the opening of a frontmatter block.
2241
+ */
2242
+ function appendLanguageBlock({ content, language }) {
2243
+ const instruction = buildLanguageInstruction(language);
2244
+ const body = content.trimEnd();
2245
+ if (body.length === 0) return instruction;
2246
+ return `${body}\n\n${LANGUAGE_BLOCK_SEPARATOR}\n\n${instruction}`;
2247
+ }
2248
+ const INSTRUCTIONS = LANGUAGE_CODES.map((code) => buildLanguageInstruction(code));
2249
+ const isBlank = (value) => value === " " || value === " ";
2250
+ /**
2251
+ * Remove one trailing block exactly as {@link appendLanguageBlock} writes it,
2252
+ * for any supported language: the separator on its own line, blank space
2253
+ * with at least one line break, the instruction, trailing whitespace. Only
2254
+ * the end of the body is inspected, so a sentence quoted mid-file is left
2255
+ * alone. Returns the body unchanged when no block closes it.
2256
+ *
2257
+ * Walks the body from its end with string operations rather than a regular
2258
+ * expression: an unanchored pattern over optional blank lines backtracks in
2259
+ * polynomial time, and a rule file padded with a few hundred thousand
2260
+ * trailing newlines would hang `rulesync import` on it.
2261
+ */
2262
+ function stripOneLanguageBlock(body) {
2263
+ const trimmed = body.trimEnd();
2264
+ const instruction = INSTRUCTIONS.find((candidate) => trimmed.endsWith(candidate));
2265
+ if (instruction === void 0) return body;
2266
+ const beforeInstruction = trimmed.slice(0, trimmed.length - instruction.length);
2267
+ const beforeGap = beforeInstruction.trimEnd();
2268
+ if (beforeGap.length === 0) return "";
2269
+ if (!beforeInstruction.slice(beforeGap.length).includes("\n")) return body;
2270
+ if (!beforeGap.endsWith(LANGUAGE_BLOCK_SEPARATOR)) return body;
2271
+ let lineStart = beforeGap.length - 3;
2272
+ while (lineStart > 0 && isBlank(beforeGap.charAt(lineStart - 1))) lineStart -= 1;
2273
+ if (lineStart > 0 && beforeGap.charAt(lineStart - 1) !== "\n") return body;
2274
+ return body.slice(0, lineStart).trimEnd();
2275
+ }
2276
+ /**
2277
+ * Remove the trailing language block(s) from an imported rule body so that
2278
+ * `rulesync import` followed by `rulesync generate` does not stack a second
2279
+ * copy. Repeats until nothing more comes off, so a file that already carries
2280
+ * two stacked blocks (generated twice by an older flow, say) comes back
2281
+ * clean. Returns the body unchanged when no block is present.
2282
+ */
2283
+ function stripLanguageBlock(body) {
2284
+ let current = body;
2285
+ for (;;) {
2286
+ const next = stripOneLanguageBlock(current);
2287
+ if (next === current) return current;
2288
+ current = next;
2289
+ }
2290
+ }
2291
+ //#endregion
2037
2292
  //#region src/utils/validation.ts
2038
2293
  /**
2039
2294
  * Shared validation utilities for input sanitization.
@@ -2105,7 +2360,15 @@ const ConfigParamsSchema = z.object({
2105
2360
  simulateCommands: optional(z.boolean()),
2106
2361
  simulateSubagents: optional(z.boolean()),
2107
2362
  simulateSkills: optional(z.boolean()),
2363
+ deriveSubprojectPathFromGlobs: optional(z.boolean()),
2108
2364
  flattenedCommandNaming: optional(FlattenedCommandNamingSchema),
2365
+ /**
2366
+ * Response language the generated rules steer the AI toward. Config-file
2367
+ * only (no CLI flag): it is a property of the project, not of one run.
2368
+ * Absent means "say nothing about language", which is why `en` is a real
2369
+ * value rather than the default.
2370
+ */
2371
+ language: optional(LanguageSchema),
2109
2372
  gitignoreTargetsOnly: optional(z.boolean()),
2110
2373
  gitignoreDestination: optional(GitignoreDestinationSchema),
2111
2374
  dryRun: optional(z.boolean()),
@@ -2245,7 +2508,9 @@ var Config = class Config {
2245
2508
  simulateCommands;
2246
2509
  simulateSubagents;
2247
2510
  simulateSkills;
2511
+ deriveSubprojectPathFromGlobs;
2248
2512
  flattenedCommandNaming;
2513
+ language;
2249
2514
  gitignoreTargetsOnly;
2250
2515
  gitignoreDestination;
2251
2516
  dryRun;
@@ -2269,7 +2534,7 @@ var Config = class Config {
2269
2534
  inputRoots;
2270
2535
  configFilePath;
2271
2536
  sources;
2272
- constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, flattenedCommandNaming, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
2537
+ constructor({ outputRoots, targets, features, verbose, delete: isDelete, global, silent, simulateCommands, simulateSubagents, simulateSkills, deriveSubprojectPathFromGlobs, flattenedCommandNaming, language, gitignoreTargetsOnly, gitignoreDestination, dryRun, check, inputRoot, inputRoots, configFilePath, sources, configFileTargets }) {
2273
2538
  assertTargetsFeaturesExclusive({
2274
2539
  targets,
2275
2540
  features
@@ -2301,7 +2566,9 @@ var Config = class Config {
2301
2566
  this.simulateCommands = simulateCommands ?? false;
2302
2567
  this.simulateSubagents = simulateSubagents ?? false;
2303
2568
  this.simulateSkills = simulateSkills ?? false;
2569
+ this.deriveSubprojectPathFromGlobs = deriveSubprojectPathFromGlobs ?? false;
2304
2570
  this.flattenedCommandNaming = flattenedCommandNaming ?? "basename";
2571
+ this.language = language;
2305
2572
  this.gitignoreTargetsOnly = gitignoreTargetsOnly ?? true;
2306
2573
  this.gitignoreDestination = gitignoreDestination ?? "gitignore";
2307
2574
  this.dryRun = dryRun ?? false;
@@ -2480,12 +2747,22 @@ var Config = class Config {
2480
2747
  getFlattenedCommandNaming() {
2481
2748
  return this.flattenedCommandNaming;
2482
2749
  }
2750
+ /**
2751
+ * The configured response language, or `undefined` when `rulesync.jsonc`
2752
+ * does not set one — in which case generation leaves language alone.
2753
+ */
2754
+ getLanguage() {
2755
+ return this.language;
2756
+ }
2483
2757
  getSimulateSubagents() {
2484
2758
  return this.simulateSubagents;
2485
2759
  }
2486
2760
  getSimulateSkills() {
2487
2761
  return this.simulateSkills;
2488
2762
  }
2763
+ getDeriveSubprojectPathFromGlobs() {
2764
+ return this.deriveSubprojectPathFromGlobs;
2765
+ }
2489
2766
  getGitignoreTargetsOnly() {
2490
2767
  return this.gitignoreTargetsOnly;
2491
2768
  }
@@ -2546,6 +2823,7 @@ const getDefaults = () => ({
2546
2823
  simulateCommands: false,
2547
2824
  simulateSubagents: false,
2548
2825
  simulateSkills: false,
2826
+ deriveSubprojectPathFromGlobs: false,
2549
2827
  flattenedCommandNaming: "basename",
2550
2828
  gitignoreTargetsOnly: true,
2551
2829
  gitignoreDestination: "gitignore",
@@ -2593,7 +2871,9 @@ const mergeConfigs = (baseConfig, localConfig) => {
2593
2871
  simulateCommands: localConfig.simulateCommands ?? baseConfig.simulateCommands,
2594
2872
  simulateSubagents: localConfig.simulateSubagents ?? baseConfig.simulateSubagents,
2595
2873
  simulateSkills: localConfig.simulateSkills ?? baseConfig.simulateSkills,
2874
+ deriveSubprojectPathFromGlobs: localConfig.deriveSubprojectPathFromGlobs ?? baseConfig.deriveSubprojectPathFromGlobs,
2596
2875
  flattenedCommandNaming: localConfig.flattenedCommandNaming ?? baseConfig.flattenedCommandNaming,
2876
+ language: localConfig.language ?? baseConfig.language,
2597
2877
  gitignoreTargetsOnly: localConfig.gitignoreTargetsOnly ?? baseConfig.gitignoreTargetsOnly,
2598
2878
  gitignoreDestination: localConfig.gitignoreDestination ?? baseConfig.gitignoreDestination,
2599
2879
  dryRun: localConfig.dryRun ?? baseConfig.dryRun,
@@ -2715,7 +2995,7 @@ function resolveEffectiveInputRoots({ cliInputRoot, cliInputRoots, configByFile,
2715
2995
  };
2716
2996
  }
2717
2997
  var ConfigResolver = class {
2718
- static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
2998
+ static async resolve({ targets, features, verbose, delete: isDelete, outputRoots, configPath = getDefaults().configPath, global, silent, simulateCommands, simulateSubagents, simulateSkills, deriveSubprojectPathFromGlobs, gitignoreTargetsOnly, dryRun, check, gitignoreDestination, inputRoot, inputRoots }, { logger } = {}) {
2719
2999
  const cwd = resolve(process.cwd());
2720
3000
  assertInputRootFieldsExclusive({
2721
3001
  inputRoot,
@@ -2814,6 +3094,11 @@ var ConfigResolver = class {
2814
3094
  file: configByFile.simulateSkills,
2815
3095
  fallback: getDefaults().simulateSkills
2816
3096
  }),
3097
+ deriveSubprojectPathFromGlobs: pick({
3098
+ cli: deriveSubprojectPathFromGlobs,
3099
+ file: configByFile.deriveSubprojectPathFromGlobs,
3100
+ fallback: getDefaults().deriveSubprojectPathFromGlobs
3101
+ }),
2817
3102
  gitignoreTargetsOnly: pick({
2818
3103
  cli: gitignoreTargetsOnly,
2819
3104
  file: configByFile.gitignoreTargetsOnly,
@@ -2838,6 +3123,7 @@ var ConfigResolver = class {
2838
3123
  configFilePath: validatedConfigPath,
2839
3124
  sources: configByFile.sources ?? getDefaults().sources,
2840
3125
  flattenedCommandNaming: configByFile.flattenedCommandNaming ?? getDefaults().flattenedCommandNaming,
3126
+ language: configByFile.language,
2841
3127
  configFileTargets: extractConfigFileTargets(configByFile.targets)
2842
3128
  });
2843
3129
  }
@@ -7241,7 +7527,80 @@ const PERMISSION_OVERRIDE_KEY_ALIASES = {
7241
7527
  hermesagent: "hermes"
7242
7528
  };
7243
7529
  //#endregion
7530
+ //#region src/utils/glob-static-prefix.ts
7531
+ /**
7532
+ * Characters that make a path segment a pattern rather than a literal name.
7533
+ *
7534
+ * `(` `)` and `!` are included because extglob syntax (`+(a|b)`, `!(x)`) uses
7535
+ * them; treating a segment that carries one as static could name a directory
7536
+ * that no matched file actually lives under.
7537
+ */
7538
+ const GLOB_METACHARACTERS = /[*?[\]{}()!]/;
7539
+ /**
7540
+ * The directory every file matched by `glob` lives under, or `undefined` when
7541
+ * the pattern pins down no such directory.
7542
+ *
7543
+ * The result is the longest leading run of POSIX path segments that contain no
7544
+ * glob metacharacter, minus the final segment when the pattern ends in one that
7545
+ * is static: `packages/api/**\/*.ts` yields `packages/api`, while
7546
+ * `packages/api/README.md` names a file and yields `packages/api` as well, and
7547
+ * a bare `README.md` yields nothing. A leading `./` is stripped. The following
7548
+ * patterns are rejected outright because the directory they would name is
7549
+ * either not inside the output root or not a single directory at all:
7550
+ *
7551
+ * - negation patterns (`!packages/**`), which exclude rather than select;
7552
+ * - absolute paths (`/etc/**`, `C:/x/**`) and backslash-separated patterns;
7553
+ * - patterns with a `..` segment anywhere;
7554
+ * - patterns with a brace expansion or any other metacharacter in their first
7555
+ * segment (`{a,b}/**`), which have no static prefix.
7556
+ *
7557
+ * Brace expansion elsewhere ends the prefix without rejecting the pattern:
7558
+ * `packages/{api,web}/**` yields `packages`, the directory both alternatives
7559
+ * share, exactly as `packages/*\/**` would.
7560
+ */
7561
+ function getGlobStaticPrefix(glob) {
7562
+ if (glob.startsWith("!") || glob.includes("\\")) return;
7563
+ let pattern = glob;
7564
+ while (pattern.startsWith("./")) pattern = pattern.slice(2);
7565
+ if (pattern.startsWith("/") || /^[A-Za-z]:/.test(pattern)) return;
7566
+ const segments = pattern.split("/");
7567
+ if (segments.includes("..")) return;
7568
+ const lastIndex = segments.length - 1;
7569
+ const prefix = [];
7570
+ for (const [index, segment] of segments.entries()) {
7571
+ if (segment === "" || GLOB_METACHARACTERS.test(segment)) break;
7572
+ if (segment === ".") continue;
7573
+ if (index === lastIndex) break;
7574
+ prefix.push(segment);
7575
+ }
7576
+ return prefix.length > 0 ? prefix.join("/") : void 0;
7577
+ }
7578
+ /**
7579
+ * The single directory every one of `globs` scopes, or `undefined` when the
7580
+ * list is empty, any pattern yields no prefix, or the patterns disagree.
7581
+ *
7582
+ * Several globs derive a directory only when each of them yields the same one:
7583
+ * picking the first pattern's prefix, or the common ancestor of all of them,
7584
+ * would place the rule somewhere the author never named.
7585
+ */
7586
+ function getGlobsStaticPrefix(globs) {
7587
+ let shared;
7588
+ for (const glob of globs) {
7589
+ const prefix = getGlobStaticPrefix(glob);
7590
+ if (prefix === void 0 || shared !== void 0 && prefix !== shared) return;
7591
+ shared = prefix;
7592
+ }
7593
+ return shared;
7594
+ }
7595
+ //#endregion
7244
7596
  //#region src/features/rules/rulesync-rule.ts
7597
+ /**
7598
+ * The `agentsmd.subprojectPath` value that asks for the path to be derived from
7599
+ * the rule's `globs` (see {@link resolveSubprojectPath}). It is a request, not a
7600
+ * path: the constructor replaces it with the derived directory, or drops it,
7601
+ * before any consumer reads the frontmatter.
7602
+ */
7603
+ const AUTO_SUBPROJECT_PATH = "auto";
7245
7604
  const RulesyncRuleFrontmatterSchema = z.object({
7246
7605
  root: z.optional(z.boolean()),
7247
7606
  localRoot: z.optional(z.boolean()),
@@ -7293,10 +7652,90 @@ const RulesyncRuleFrontmatterSchema = z.object({
7293
7652
  facet: z.optional(z.enum(["policies", "output-contracts"]))
7294
7653
  }))
7295
7654
  });
7655
+ /**
7656
+ * The `agentsmd.subprojectPath` every consumer should act on, resolved once so
7657
+ * that no target has to know how it came about:
7658
+ *
7659
+ * 1. an explicit directory in the frontmatter wins, and an explicit `""` is an
7660
+ * opt-out: the rule keeps its default placement and nothing is derived;
7661
+ * 2. otherwise, when the rule says `"auto"` or `deriveFromGlobs` is on, the
7662
+ * directory the rule's `globs` share (see `getGlobsStaticPrefix`);
7663
+ * 3. otherwise none, which keeps the rule in the target's modular directory.
7664
+ *
7665
+ * A root rule never nests, so it never derives. When a derivation yields
7666
+ * nothing the rule falls back to step 3, and only a rule that asked with
7667
+ * `"auto"` is told, once: it named a placement it did not get, so a warning
7668
+ * names the file to fix (an error would stop every other rule from
7669
+ * generating). The config option applies to every non-root rule, most of
7670
+ * which are general guidance whose globs, if any, were written as activation
7671
+ * hints (`["src/**\/*.ts", "test/**\/*.ts"]`) rather than as a directory;
7672
+ * warning about each of those on every generate would drown out real ones,
7673
+ * so config-driven derivation falls back silently.
7674
+ */
7675
+ function resolveSubprojectPath({ frontmatter, deriveFromGlobs, rulePath }) {
7676
+ const authored = frontmatter.agentsmd?.subprojectPath;
7677
+ if (authored === "") return;
7678
+ if (typeof authored === "string" && authored !== "auto") return authored;
7679
+ const requested = authored === AUTO_SUBPROJECT_PATH;
7680
+ if (!requested && !deriveFromGlobs) return;
7681
+ if (frontmatter.root) {
7682
+ if (requested) warnOnceWithFallback(void 0, `Ignoring agentsmd.subprojectPath: "${AUTO_SUBPROJECT_PATH}" on the root rule ${rulePath}: a root rule is never written as a nested AGENTS.md.`);
7683
+ return;
7684
+ }
7685
+ const globs = Array.isArray(frontmatter.globs) ? frontmatter.globs : [];
7686
+ const derived = getGlobsStaticPrefix(globs);
7687
+ if (derived === void 0 && requested) warnOnceWithFallback(void 0, `Could not derive agentsmd.subprojectPath for ${rulePath} from globs ${JSON.stringify(globs)}: every glob must start with the same wildcard-free directory (e.g. "packages/api/**/*"). The rule is generated without a nested AGENTS.md; set agentsmd.subprojectPath explicitly to nest it.`);
7688
+ return derived;
7689
+ }
7690
+ /**
7691
+ * `frontmatter` with `agentsmd.subprojectPath` replaced by its resolved value,
7692
+ * or removed when there is none, so the `"auto"` request never reaches a
7693
+ * consumer as if it were a directory name. An `agentsmd` block that held
7694
+ * nothing but the request goes with it, so a consumer sees the same shape it
7695
+ * would for a rule that never mentioned `agentsmd`. An authored `""` is left
7696
+ * as written: every consumer already reads it as "no nesting".
7697
+ */
7698
+ function withResolvedSubprojectPath({ frontmatter, deriveFromGlobs, rulePath }) {
7699
+ const authored = frontmatter.agentsmd?.subprojectPath;
7700
+ const resolved = resolveSubprojectPath({
7701
+ frontmatter,
7702
+ deriveFromGlobs,
7703
+ rulePath
7704
+ });
7705
+ if (resolved === authored || resolved === void 0 && authored !== "auto") return frontmatter;
7706
+ const { subprojectPath: _authored, ...agentsmd } = frontmatter.agentsmd ?? {};
7707
+ if (resolved !== void 0) return {
7708
+ ...frontmatter,
7709
+ agentsmd: {
7710
+ ...agentsmd,
7711
+ subprojectPath: resolved
7712
+ }
7713
+ };
7714
+ if (Object.keys(agentsmd).length === 0) {
7715
+ const { agentsmd: _empty, ...rest } = frontmatter;
7716
+ return rest;
7717
+ }
7718
+ return {
7719
+ ...frontmatter,
7720
+ agentsmd
7721
+ };
7722
+ }
7296
7723
  var RulesyncRule = class RulesyncRule extends RulesyncFile {
7724
+ /**
7725
+ * The frontmatter consumers read. It differs from what the file says in one
7726
+ * place: `agentsmd.subprojectPath` holds the resolved directory (see
7727
+ * `resolveSubprojectPath`), while `authoredFrontmatter` and
7728
+ * `getFileContent()` keep the authored value, so a rule written back out
7729
+ * still says `"auto"`.
7730
+ */
7297
7731
  frontmatter;
7732
+ /**
7733
+ * The frontmatter as written, after schema defaults but before
7734
+ * `agentsmd.subprojectPath` resolution: what `getFileContent()` serializes.
7735
+ */
7736
+ authoredFrontmatter;
7298
7737
  body;
7299
- constructor({ frontmatter, body, ...rest }) {
7738
+ constructor({ frontmatter, body, deriveSubprojectPathFromGlobs = false, ...rest }) {
7300
7739
  const parseResult = RulesyncRuleFrontmatterSchema.safeParse(frontmatter);
7301
7740
  if (!parseResult.success && rest.validate !== false) throw new Error(`Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(parseResult.error)}`);
7302
7741
  const parsedFrontmatter = parseResult.success ? parseResult.data : {
@@ -7307,7 +7746,12 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
7307
7746
  ...rest,
7308
7747
  fileContent: stringifyFrontmatter(body, parsedFrontmatter)
7309
7748
  });
7310
- this.frontmatter = parsedFrontmatter;
7749
+ this.authoredFrontmatter = parsedFrontmatter;
7750
+ this.frontmatter = withResolvedSubprojectPath({
7751
+ frontmatter: parsedFrontmatter,
7752
+ deriveFromGlobs: deriveSubprojectPathFromGlobs,
7753
+ rulePath: join(rest.relativeDirPath, rest.relativeFilePath)
7754
+ });
7311
7755
  this.body = body;
7312
7756
  }
7313
7757
  static getSettablePaths() {
@@ -7316,9 +7760,23 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
7316
7760
  legacy: { relativeDirPath: RULESYNC_RELATIVE_DIR_PATH }
7317
7761
  };
7318
7762
  }
7763
+ /**
7764
+ * The frontmatter to act on: `agentsmd.subprojectPath` is the resolved
7765
+ * placement, never `"auto"`.
7766
+ */
7319
7767
  getFrontmatter() {
7320
7768
  return this.frontmatter;
7321
7769
  }
7770
+ /**
7771
+ * The frontmatter as the file states it, `agentsmd.subprojectPath: "auto"`
7772
+ * included. This is the view to hand back to whoever edits the file (the
7773
+ * MCP rule tools): returning the resolved placement instead would make a
7774
+ * get → edit → put round trip hardcode the derived directory, or drop the
7775
+ * request when nothing could be derived.
7776
+ */
7777
+ getAuthoredFrontmatter() {
7778
+ return this.authoredFrontmatter;
7779
+ }
7322
7780
  validate() {
7323
7781
  if (!this.frontmatter) return {
7324
7782
  success: true,
@@ -7334,7 +7792,7 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
7334
7792
  error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
7335
7793
  };
7336
7794
  }
7337
- static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true }) {
7795
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true, deriveSubprojectPathFromGlobs = false }) {
7338
7796
  const dirPath = relativeDirPath ?? this.getSettablePaths().recommended.relativeDirPath;
7339
7797
  const filePath = join(outputRoot, dirPath, relativeFilePath);
7340
7798
  const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
@@ -7353,7 +7811,8 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
7353
7811
  relativeFilePath,
7354
7812
  frontmatter: validatedFrontmatter,
7355
7813
  body: content.trim(),
7356
- validate
7814
+ validate,
7815
+ deriveSubprojectPathFromGlobs
7357
7816
  });
7358
7817
  }
7359
7818
  getBody() {
@@ -7381,6 +7840,21 @@ function containsPathSeparator(name) {
7381
7840
  return name.includes("/") || name.includes("\\");
7382
7841
  }
7383
7842
  /**
7843
+ * The permission bits worth carrying from a source file: only an executable
7844
+ * one has a mode the copy must keep, and only its read and execute bits plus
7845
+ * the owner's write bit are kept. Group and world write bits are dropped the
7846
+ * way a umask would drop them, so a source sitting on a mount that reports
7847
+ * everything as 0777 does not turn the generated copy world-writable; setuid,
7848
+ * setgid and sticky are never carried. Windows reports no executable bit, so
7849
+ * nothing is carried there and the copy takes the platform default, as it
7850
+ * always has.
7851
+ */
7852
+ function carriedFileMode(mode) {
7853
+ const permissionBits = mode & 493;
7854
+ if ((permissionBits & 73) === 0) return {};
7855
+ return { fileMode: permissionBits };
7856
+ }
7857
+ /**
7384
7858
  * Directories that hold credentials. Excluding these protects something, so
7385
7859
  * their exclusion is reported rather than silent.
7386
7860
  */
@@ -8094,6 +8568,7 @@ var AiDir = class AiDir {
8094
8568
  * files says so rather than generating a directory that is quietly short.
8095
8569
  */
8096
8570
  static warnOnCarriedWalkLimits({ reportedDirPath, truncations, unreadablePaths }) {
8571
+ if (truncations.size > 0 || unreadablePaths.length > 0) recordIncompleteCarriedFiles();
8097
8572
  if (truncations.has("depth")) warnOnceWithFallback(void 0, `Not carrying the entries more than 12 directories below ${reportedDirPath}: a skill directory is walked to that depth only. A symbolic link that reaches a large tree is the usual cause.`);
8098
8573
  if (truncations.has("count")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} beyond the first ${MAX_CARRIED_FILES}: a directory may carry at most that many files. A symbolic link that reaches a large tree is the usual cause.`);
8099
8574
  if (truncations.has("directories")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} below the first ${MAX_CARRIED_DIRECTORIES} directories: a directory may carry files from at most that many directories. A symbolic link that reaches a large tree is the usual cause.`);
@@ -8192,12 +8667,15 @@ var AiDir = class AiDir {
8192
8667
  try {
8193
8668
  fileHandle = await open(classifiedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
8194
8669
  } catch (error) {
8670
+ recordIncompleteCarriedFiles();
8195
8671
  warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
8196
8672
  continue;
8197
8673
  }
8198
8674
  try {
8199
- const fileSize = (await fileHandle.stat()).size;
8675
+ const fileStats = await fileHandle.stat();
8676
+ const fileSize = fileStats.size;
8200
8677
  if (carriedBytes + fileSize > 104857600) {
8678
+ recordIncompleteCarriedFiles();
8201
8679
  warnOnceWithFallback(void 0, `Not carrying ${filteredPaths.length - index} of the ${filteredPaths.length} entries under ${reportedDirPath}: a directory may carry at most ${MAX_CARRIED_BYTES / 1024 / 1024}MB. A symbolic link that reaches a large tree is the usual cause.`);
8202
8680
  break;
8203
8681
  }
@@ -8205,9 +8683,11 @@ var AiDir = class AiDir {
8205
8683
  carriedBytes += fileBuffer.byteLength;
8206
8684
  files.push({
8207
8685
  relativeFilePathToDirPath: relative(dirPath, filePath),
8208
- fileBuffer
8686
+ fileBuffer,
8687
+ ...carriedFileMode(fileStats.mode)
8209
8688
  });
8210
8689
  } catch (error) {
8690
+ recordIncompleteCarriedFiles();
8211
8691
  warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
8212
8692
  } finally {
8213
8693
  await fileHandle.close();
@@ -8240,6 +8720,9 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
8240
8720
  targets: z._default(RulesyncTargetsSchema, ["*"]),
8241
8721
  "disable-model-invocation": z.optional(z.boolean()),
8242
8722
  "user-invocable": z.optional(z.boolean()),
8723
+ license: z.optional(z.string()),
8724
+ compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
8725
+ metadata: z.optional(z.looseObject({})),
8243
8726
  claudecode: z.optional(z.looseObject({
8244
8727
  when_to_use: z.optional(z.string()),
8245
8728
  "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
@@ -8289,7 +8772,7 @@ const RulesyncSkillFrontmatterSchema = z.looseObject({
8289
8772
  kilo: z.optional(z.looseObject({
8290
8773
  "allowed-tools": z.optional(z.array(z.string())),
8291
8774
  license: z.optional(z.string()),
8292
- compatibility: z.optional(z.looseObject({})),
8775
+ compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
8293
8776
  metadata: z.optional(z.looseObject({}))
8294
8777
  })),
8295
8778
  kiro: z.optional(z.looseObject({
@@ -8666,6 +9149,61 @@ function resolveDisableModelInvocation({ rootFrontmatter, section }) {
8666
9149
  function resolveUserInvocable({ rootFrontmatter, section }) {
8667
9150
  return section?.["user-invocable"] ?? rootFrontmatter["user-invocable"];
8668
9151
  }
9152
+ /**
9153
+ * Resolve the effective `license` value for a tool skill.
9154
+ *
9155
+ * The rulesync skill frontmatter exposes a root-level `license` default that
9156
+ * applies to every tool modelling the Agent Skills standard field; the list of
9157
+ * those tools lives in `docs/reference/file-formats.md`. Each tool's own
9158
+ * section may override that default with a per-target value. A defined section
9159
+ * value always wins over the root default.
9160
+ *
9161
+ * The section value type is generic because `factorydroid` deliberately types
9162
+ * the packaging fields as `unknown` (Droid never validates them).
9163
+ *
9164
+ * @returns The resolved value, or `undefined` when neither value is set.
9165
+ */
9166
+ function resolveLicense({ rootFrontmatter, section }) {
9167
+ return section?.license ?? rootFrontmatter.license;
9168
+ }
9169
+ /**
9170
+ * Resolve the effective `compatibility` value for a tool skill.
9171
+ *
9172
+ * The rulesync skill frontmatter exposes a root-level `compatibility` default
9173
+ * that applies to every tool modelling the Agent Skills standard field; the
9174
+ * list of those tools lives in `docs/reference/file-formats.md`. Each tool's
9175
+ * own section may override that default with a per-target value. A defined
9176
+ * section value always wins over the root default.
9177
+ *
9178
+ * The root value keeps the rulesync shape (a string, or the legacy object
9179
+ * form); any per-target normalization — such as the Agent Skills spec's string
9180
+ * coercion — stays with the adapter.
9181
+ * The section value type is generic because `factorydroid` deliberately types
9182
+ * the packaging fields as `unknown` (Droid never validates them).
9183
+ *
9184
+ * @returns The resolved value, or `undefined` when neither value is set.
9185
+ */
9186
+ function resolveCompatibility({ rootFrontmatter, section }) {
9187
+ return section?.compatibility ?? rootFrontmatter.compatibility;
9188
+ }
9189
+ /**
9190
+ * Resolve the effective `metadata` value for a tool skill.
9191
+ *
9192
+ * The rulesync skill frontmatter exposes a root-level `metadata` default that
9193
+ * applies to every tool modelling the Agent Skills standard field; the list of
9194
+ * those tools lives in `docs/reference/file-formats.md`. Each tool's own
9195
+ * section may override that default with a per-target value. A defined section
9196
+ * value always wins over the root default; the two maps are never merged key
9197
+ * by key.
9198
+ *
9199
+ * The section value type is generic because `factorydroid` deliberately types
9200
+ * the packaging fields as `unknown` (Droid never validates them).
9201
+ *
9202
+ * @returns The resolved value, or `undefined` when neither value is set.
9203
+ */
9204
+ function resolveMetadata({ rootFrontmatter, section }) {
9205
+ return section?.metadata ?? rootFrontmatter.metadata;
9206
+ }
8669
9207
  //#endregion
8670
9208
  //#region src/constants/augmentcode-paths.ts
8671
9209
  const AUGMENTCODE_DIR = ".augment";
@@ -10610,6 +11148,40 @@ var RovodevCheck = class extends AggregatedToolCheck {
10610
11148
  }
10611
11149
  };
10612
11150
  //#endregion
11151
+ //#region src/constants/claudecode-paths.ts
11152
+ /**
11153
+ * Claude Code configuration-layout conventions.
11154
+ *
11155
+ * Single source of truth for where Claude Code expects its files
11156
+ * (directories, file names, scope-specific paths). Every feature module
11157
+ * (rules, commands, skills, subagents, ignore, mcp, permissions, hooks)
11158
+ * and the gitignore entry registry import from here, so a change in the
11159
+ * Claude Code conventions is a change to this file only.
11160
+ */
11161
+ /** Root directory for Claude Code configuration, relative to the scope root. */
11162
+ const CLAUDECODE_DIR = ".claude";
11163
+ const CLAUDECODE_RULE_FILE_NAME = "CLAUDE.md";
11164
+ const CLAUDECODE_LOCAL_RULE_FILE_NAME = "CLAUDE.local.md";
11165
+ /** Modular rules directory name under `.claude/` (current format). */
11166
+ const CLAUDECODE_RULES_DIR_NAME = "rules";
11167
+ /** Memories directory name under `.claude/` (legacy format). */
11168
+ const CLAUDECODE_MEMORIES_DIR_NAME = "memories";
11169
+ const CLAUDECODE_COMMANDS_DIR_PATH = join(CLAUDECODE_DIR, "commands");
11170
+ const CLAUDECODE_AGENTS_DIR_PATH = join(CLAUDECODE_DIR, "agents");
11171
+ const CLAUDECODE_SKILLS_DIR_PATH = join(CLAUDECODE_DIR, "skills");
11172
+ const CLAUDECODE_SCHEDULED_TASKS_DIR_PATH = join(CLAUDECODE_DIR, "scheduled-tasks");
11173
+ const CLAUDECODE_SETTINGS_FILE_NAME = "settings.json";
11174
+ const CLAUDECODE_SETTINGS_LOCAL_FILE_NAME = "settings.local.json";
11175
+ /**
11176
+ * The published JSON Schema for both settings files (SchemaStore). Written as
11177
+ * the `$schema` key of every settings file rulesync generates, when the file
11178
+ * does not already state one, so editors offer completion, hover
11179
+ * documentation and validation for the keys around the ones rulesync writes.
11180
+ */
11181
+ const CLAUDECODE_SETTINGS_SCHEMA_URL = "https://json.schemastore.org/claude-code-settings.json";
11182
+ const CLAUDECODE_MCP_FILE_NAME = ".mcp.json";
11183
+ const CLAUDECODE_GLOBAL_MCP_FILE_NAME = ".claude.json";
11184
+ //#endregion
10613
11185
  //#region src/constants/codexcli-paths.ts
10614
11186
  const CODEXCLI_DIR = ".codex";
10615
11187
  const CODEXCLI_PROMPTS_DIR_PATH = join(CODEXCLI_DIR, "prompts");
@@ -10630,6 +11202,52 @@ const CODEXCLI_OVERRIDE_KEYS = [
10630
11202
  "tui"
10631
11203
  ];
10632
11204
  //#endregion
11205
+ //#region src/utils/quote-value.ts
11206
+ /**
11207
+ * How much of a value read off disk a diagnostic quotes.
11208
+ *
11209
+ * Enough to recognize which entry is meant, and no more. A warning names the
11210
+ * offending value so the reader can find it, but the values these warnings
11211
+ * quote come from files rulesync did not write — a tool's own settings, a
11212
+ * machine-local overrides file, a repository fetched from elsewhere — and they
11213
+ * no longer stop at a terminal: they travel into a `--json` document another
11214
+ * program parses and into an MCP result an agent reads as context. A command
11215
+ * line or a header is the shape most likely to carry a credential, and a long
11216
+ * value is the shape most likely to carry instructions aimed at the agent.
11217
+ */
11218
+ const MAX_QUOTED_VALUE_LENGTH = 60;
11219
+ /**
11220
+ * A short, quotable rendering of a value for a diagnostic.
11221
+ *
11222
+ * Serialized rather than interpolated, because an unquoted value is what lets a
11223
+ * crafted one read as a second line; stripped of the control characters
11224
+ * `JSON.stringify` leaves intact (it escapes C0 only, not the C1 range or the
11225
+ * bidirectional overrides); and truncated.
11226
+ */
11227
+ function quoteValueForWarning(value) {
11228
+ return truncateText({
11229
+ text: stripControlCharacters(serialize(value)),
11230
+ maxLength: MAX_QUOTED_VALUE_LENGTH,
11231
+ suffix: "…(truncated)"
11232
+ });
11233
+ }
11234
+ function serialize(value) {
11235
+ try {
11236
+ return JSON.stringify(value, stripStrings) ?? String(value);
11237
+ } catch {
11238
+ return `[unserializable ${typeof value}]`;
11239
+ }
11240
+ }
11241
+ /**
11242
+ * Strip the control characters out of every string before `JSON.stringify`
11243
+ * sees it, not only out of the document it produces: `JSON.stringify` escapes
11244
+ * a C0 character into the six literal characters `\u001b`, which no later pass
11245
+ * over the output can recognize as a control character again.
11246
+ */
11247
+ function stripStrings(_key, value) {
11248
+ return typeof value === "string" ? stripControlCharacters(value) : value;
11249
+ }
11250
+ //#endregion
10633
11251
  //#region src/features/shared/shared-config-gateway.ts
10634
11252
  /**
10635
11253
  * Rebuild a parsed document without its prototype-pollution keys.
@@ -10765,39 +11383,35 @@ function detectJsoncFormattingOptions({ text, root }) {
10765
11383
  eol
10766
11384
  };
10767
11385
  }
10768
- /**
10769
- * Whether the document states a key no edit-based write can be trusted with.
10770
- *
10771
- * Two kinds, both answered from the syntax tree because the parsed value no
10772
- * longer knows about either:
10773
- *
10774
- * - A key stated twice. That is legal JSON text which every reader resolves
10775
- * last-wins, while `modify` edits the *first* occurrence — so an edit-based
10776
- * write would land on the dead copy and leave the live one saying whatever
10777
- * it said before. For an owned key that is a silent ownership failure: a
10778
- * `deny` rulesync just wrote would sit above the `allow` the tool reads.
10779
- * - `__proto__`, `constructor` or `prototype`. None survives into the parsed
10780
- * document — a nested one is dropped, a root-level `__proto__` replaces the
10781
- * root's prototype — so an edit-based write would find no difference to
10782
- * apply and leave the key in the file.
10783
- *
10784
- * The whole-document writer resolves duplicates last-wins and drops pollution
10785
- * keys, which is what it has always done, so those files go to it.
10786
- */
10787
- function statesUneditableKeys(node) {
10788
- if (node.type === "array") return (node.children ?? []).some((child) => statesUneditableKeys(child));
10789
- if (node.type !== "object") return false;
11386
+ function uneditableKeyOf(node) {
11387
+ if (node.type === "array") {
11388
+ for (const child of node.children ?? []) {
11389
+ const found = uneditableKeyOf(child);
11390
+ if (found !== void 0) return found;
11391
+ }
11392
+ return;
11393
+ }
11394
+ if (node.type !== "object") return void 0;
10790
11395
  const seen = /* @__PURE__ */ new Set();
10791
11396
  for (const property of node.children ?? []) {
10792
11397
  const key = property.children?.[0]?.value;
10793
11398
  if (typeof key === "string") {
10794
- if (seen.has(key) || isPrototypePollutionKey(key)) return true;
11399
+ if (isPrototypePollutionKey(key)) return {
11400
+ kind: "prototype-pollution",
11401
+ key
11402
+ };
11403
+ if (seen.has(key)) return {
11404
+ kind: "duplicate",
11405
+ key
11406
+ };
10795
11407
  seen.add(key);
10796
11408
  }
10797
11409
  const value = property.children?.[1];
10798
- if (value !== void 0 && statesUneditableKeys(value)) return true;
11410
+ if (value !== void 0) {
11411
+ const found = uneditableKeyOf(value);
11412
+ if (found !== void 0) return found;
11413
+ }
10799
11414
  }
10800
- return false;
10801
11415
  }
10802
11416
  /**
10803
11417
  * The offset just past the whitespace and comments starting at `from`.
@@ -10978,7 +11592,8 @@ function notesAt({ text, from }) {
10978
11592
  /**
10979
11593
  * Detach the notes written at the point where the object at `path` will take a
10980
11594
  * new key: after its last property (and around the comma a trailing-comma file
10981
- * spells there), or just inside the `{` when it has no properties yet.
11595
+ * spells there), or just inside the `{` when it has no properties yet or when
11596
+ * the key is to be written first (`at: "start"`).
10982
11597
  *
10983
11598
  * `modify` computes its insert from exactly that point — in front of a note
10984
11599
  * written there — so applying the edit unchanged re-emits the note *after* the
@@ -10991,11 +11606,11 @@ function notesAt({ text, from }) {
10991
11606
  *
10992
11607
  * Returns `undefined` when there is no such note, which is the common case.
10993
11608
  */
10994
- function detachTrailingNote({ text, path }) {
11609
+ function detachInsertionNote({ text, path, at }) {
10995
11610
  const root = parseTree(text, [], { allowTrailingComma: true });
10996
11611
  const object = root === void 0 ? void 0 : findNodeAtLocation(root, [...path]);
10997
11612
  if (object?.type !== "object") return void 0;
10998
- const property = object.children?.at(-1);
11613
+ const property = at === "end" ? object.children?.at(-1) : void 0;
10999
11614
  const anchorKey = property?.children?.[0]?.value;
11000
11615
  if (property !== void 0 && typeof anchorKey !== "string") return void 0;
11001
11616
  const spans = notesAt({
@@ -11012,7 +11627,7 @@ function detachTrailingNote({ text, path }) {
11012
11627
  };
11013
11628
  }
11014
11629
  /**
11015
- * Put a note detached by {@link detachTrailingNote} back where it was: after
11630
+ * Put a note detached by {@link detachInsertionNote} back where it was: after
11016
11631
  * the property it describes (behind the comma the insert gave that property),
11017
11632
  * or just inside the `{` of the object it was written in when there was no
11018
11633
  * property to describe. Returns `undefined` if that place can no longer be
@@ -11035,16 +11650,23 @@ function reattachTrailingNote({ text, path, anchorKey, note }) {
11035
11650
  return text.slice(0, cursor) + note + text.slice(cursor);
11036
11651
  }
11037
11652
  /**
11038
- * Write `value` at `[...path, key]`, keeping the trailing note of the property
11039
- * the new key is inserted after (see {@link detachTrailingNote}). Replacing an
11040
- * existing key needs none of this: `modify` rewrites the value's own span and
11041
- * leaves every comment where it is.
11042
- */
11043
- function insertJsoncProperty({ text, path, key, value, options }) {
11044
- const write = (source) => applyEdits(source, modify(source, [...path, key], value, options));
11045
- const detached = detachTrailingNote({
11653
+ * Write `value` at `[...path, key]`, keeping the note written at the point of
11654
+ * insertion where its author put it (see {@link detachInsertionNote}): the
11655
+ * trailing note of the property the new key is appended after, or — for a
11656
+ * `leading` key, which `modify` places before every other property the note
11657
+ * written just inside the `{`. Replacing an existing key needs none of this:
11658
+ * `modify` rewrites the value's own span and leaves every comment where it is.
11659
+ */
11660
+ function insertJsoncProperty({ text, path, key, value, options, leading = false }) {
11661
+ const modification = leading ? {
11662
+ ...options,
11663
+ getInsertionIndex: () => 0
11664
+ } : options;
11665
+ const write = (source) => applyEdits(source, modify(source, [...path, key], value, modification));
11666
+ const detached = detachInsertionNote({
11046
11667
  text,
11047
- path
11668
+ path,
11669
+ at: leading ? "start" : "end"
11048
11670
  });
11049
11671
  if (detached === void 0) return write(text);
11050
11672
  return reattachTrailingNote({
@@ -11164,7 +11786,7 @@ function countJsoncEdits({ base, next, depth }) {
11164
11786
  * large enough and enough of it changing, the product of the two is what
11165
11787
  * {@link JSONC_EDIT_BUDGET_BYTES} keeps off this path.
11166
11788
  */
11167
- function applyJsoncObjectEdits({ text, base, next, path, options }) {
11789
+ function applyJsoncObjectEdits({ text, base, next, path, options, leadingKeys }) {
11168
11790
  let result = text;
11169
11791
  for (const [key, value] of Object.entries(next)) {
11170
11792
  if (isPrototypePollutionKey(key)) continue;
@@ -11183,7 +11805,8 @@ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11183
11805
  base: previous,
11184
11806
  next: value,
11185
11807
  path: [...path, key],
11186
- options
11808
+ options,
11809
+ leadingKeys: []
11187
11810
  });
11188
11811
  continue;
11189
11812
  }
@@ -11197,7 +11820,8 @@ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11197
11820
  path,
11198
11821
  key,
11199
11822
  value,
11200
- options
11823
+ options,
11824
+ leading: leadingKeys.includes(key)
11201
11825
  });
11202
11826
  }
11203
11827
  for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) result = removeJsoncProperty({
@@ -11227,7 +11851,7 @@ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11227
11851
  * here have already decided (via `invalidRootPolicy`) that such a file is
11228
11852
  * replaced;
11229
11853
  * - a file stating the same key twice, or using `__proto__`, `constructor` or
11230
- * `prototype` as a key (see {@link statesUneditableKeys});
11854
+ * `prototype` as a key (see {@link uneditableKeyOf});
11231
11855
  * - a file so large, with so much of it changing, that editing it key by key
11232
11856
  * would take longer than a user would wait (see
11233
11857
  * {@link JSONC_EDIT_BUDGET_BYTES}), or changed keys that write more new text
@@ -11235,26 +11859,54 @@ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11235
11859
  * {@link JSONC_EDIT_WRITTEN_BYTES});
11236
11860
  * - a file the editor itself refuses, which it answers with an exception
11237
11861
  * rather than a result.
11862
+ *
11863
+ * Every one of those fallbacks except the empty file is reported once per run
11864
+ * through `logger` (or the fallback logger when none is given): the author's
11865
+ * comments are what the edit path exists to keep, and a `generate` that drops
11866
+ * them should say so rather than let the loss show up in the next diff.
11867
+ * `filePath` names the file in that warning; callers that know it should pass
11868
+ * it, since the once-per-run token is the message itself and two nameless
11869
+ * files would share one.
11870
+ *
11871
+ * `leadingKeys` names the root keys that go in front of every other property
11872
+ * when the edit path inserts them (the whole-document writer keeps the order
11873
+ * `document` states, so the caller places them there itself). A key the file
11874
+ * already has stays where the file put it.
11238
11875
  */
11239
- function serializeSharedConfig({ format, document, existingContent }) {
11876
+ function serializeSharedConfig({ format, document, existingContent, leadingKeys = [], filePath, logger }) {
11240
11877
  const whole = stringifySharedConfig({
11241
11878
  format,
11242
11879
  document
11243
11880
  });
11244
11881
  if (format !== "jsonc" || existingContent.trim() === "") return whole;
11882
+ const wholeBecause = (reason) => {
11883
+ warnAboutWholeJsoncRewrite({
11884
+ filePath,
11885
+ reason,
11886
+ logger
11887
+ });
11888
+ return whole;
11889
+ };
11245
11890
  try {
11246
11891
  const errors = [];
11247
11892
  const root = parseTree(existingContent, errors, { allowTrailingComma: true });
11248
- if (root === void 0 || errors.length > 0 || root.type !== "object" || statesUneditableKeys(root)) return whole;
11893
+ if (root === void 0 || errors.length > 0) return wholeBecause("it could not be parsed");
11894
+ if (root.type !== "object") return wholeBecause("its root is not an object");
11895
+ const uneditable = uneditableKeyOf(root);
11896
+ if (uneditable !== void 0) {
11897
+ const key = quoteValueForWarning(uneditable.key);
11898
+ return wholeBecause(uneditable.kind === "duplicate" ? `it states the key ${key} twice` : `it uses the key ${key}, which rulesync drops from every document it parses`);
11899
+ }
11249
11900
  const base = sanitizeSharedConfigValue(getNodeValue(root));
11250
- if (!isPlainObject$1(base)) return whole;
11901
+ if (!isPlainObject$1(base)) return wholeBecause("its root is not an object");
11251
11902
  const span = Math.max(existingContent.length, whole.length);
11252
11903
  const cost = countJsoncEdits({
11253
11904
  base,
11254
11905
  next: document,
11255
11906
  depth: 0
11256
11907
  });
11257
- if (cost.written > JSONC_EDIT_WRITTEN_BYTES || cost.edits * span > JSONC_EDIT_BUDGET_BYTES) return whole;
11908
+ if (cost.written > JSONC_EDIT_WRITTEN_BYTES) return wholeBecause("its changed keys write more new text than editing in place can afford");
11909
+ if (cost.edits * span > JSONC_EDIT_BUDGET_BYTES) return wholeBecause("it is too large, with too much of it changing, to edit key by key");
11258
11910
  return applyJsoncObjectEdits({
11259
11911
  text: existingContent,
11260
11912
  base,
@@ -11263,13 +11915,25 @@ function serializeSharedConfig({ format, document, existingContent }) {
11263
11915
  options: { formattingOptions: detectJsoncFormattingOptions({
11264
11916
  text: existingContent,
11265
11917
  root
11266
- }) }
11918
+ }) },
11919
+ leadingKeys
11267
11920
  });
11268
- } catch {
11269
- return whole;
11921
+ } catch (error) {
11922
+ return wholeBecause(`the editor refused it (${stripControlCharacters(formatError(error))})`);
11270
11923
  }
11271
11924
  }
11272
11925
  /**
11926
+ * Report, once per run and file, that a JSONC file is being written whole
11927
+ * instead of edited in place. Named by `filePath` when the caller knows it;
11928
+ * a caller serializing text it never read from disk has no name to give.
11929
+ */
11930
+ function warnAboutWholeJsoncRewrite({ filePath, reason, logger }) {
11931
+ const subject = filePath === void 0 ? "A shared JSONC config file" : quoteForLog(filePath);
11932
+ try {
11933
+ warnOnceWithFallback(logger, `${subject} is rewritten whole rather than edited in place because ${reason}; comments, blank lines and key order in the existing file are not preserved.`);
11934
+ } catch {}
11935
+ }
11936
+ /**
11273
11937
  * Shallow merge: every top-level key in `patch` replaces the base key
11274
11938
  * wholesale; all other base keys are preserved. The policy for a feature that
11275
11939
  * owns a fixed set of top-level keys.
@@ -11307,6 +11971,7 @@ function mergeSharedConfigDeep({ base, patch }) {
11307
11971
  return result;
11308
11972
  }
11309
11973
  const CLAUDE_SETTINGS_SHARED_FILE_KEY = ".claude/settings.json";
11974
+ const CLAUDE_SETTINGS_LOCAL_SHARED_FILE_KEY = ".claude/settings.local.json";
11310
11975
  const HERMES_CONFIG_SHARED_FILE_KEY = ".hermes/config.yaml";
11311
11976
  const HERMES_WIN32_CONFIG_SHARED_FILE_KEY = "AppData/Local/hermes/config.yaml";
11312
11977
  const HERMES_HOME_CONFIG_SHARED_FILE_KEY = "config.yaml";
@@ -11416,9 +12081,28 @@ const ZCODE_CONFIG_DECLARATION = {
11416
12081
  ownedKeys: ["mcp"]
11417
12082
  } }
11418
12083
  };
12084
+ /**
12085
+ * What the two Claude Code settings files have in common: both are plain JSON,
12086
+ * and both validate against the one published schema, which the gateway
12087
+ * points every file it writes at. `.claude/settings.local.json` is the same
12088
+ * file with fewer writers — the ignore feature's `fileMode: "local"` and the
12089
+ * project-scope `language` setting go there — so the shape is declared once
12090
+ * and the feature sets separately.
12091
+ *
12092
+ * `language` is the rules feature's: the root `language` key of
12093
+ * `rulesync.jsonc` is written natively for Claude Code instead of as a prompt
12094
+ * block in CLAUDE.md. At project scope it lands in the local file (a
12095
+ * per-developer preference, not a team commitment); at global scope it lands
12096
+ * in `~/.claude/settings.json`, because Claude Code reads no
12097
+ * `~/.claude/settings.local.json`.
12098
+ */
12099
+ const CLAUDE_SETTINGS_FILE_SHAPE = {
12100
+ format: "json",
12101
+ ensuredKeys: { $schema: CLAUDECODE_SETTINGS_SCHEMA_URL }
12102
+ };
11419
12103
  const SHARED_CONFIG_OWNERSHIP = {
11420
12104
  [CLAUDE_SETTINGS_SHARED_FILE_KEY]: {
11421
- format: "json",
12105
+ ...CLAUDE_SETTINGS_FILE_SHAPE,
11422
12106
  features: {
11423
12107
  ignore: {
11424
12108
  kind: "custom",
@@ -11431,6 +12115,23 @@ const SHARED_CONFIG_OWNERSHIP = {
11431
12115
  permissions: {
11432
12116
  kind: "custom",
11433
12117
  policyFunction: "applyPermissions"
12118
+ },
12119
+ rules: {
12120
+ kind: "replace-owned-keys",
12121
+ ownedKeys: ["language"]
12122
+ }
12123
+ }
12124
+ },
12125
+ [CLAUDE_SETTINGS_LOCAL_SHARED_FILE_KEY]: {
12126
+ ...CLAUDE_SETTINGS_FILE_SHAPE,
12127
+ features: {
12128
+ ignore: {
12129
+ kind: "custom",
12130
+ policyFunction: "applyIgnoreReadDenies"
12131
+ },
12132
+ rules: {
12133
+ kind: "replace-owned-keys",
12134
+ ownedKeys: ["language"]
11434
12135
  }
11435
12136
  }
11436
12137
  },
@@ -11872,17 +12573,69 @@ const SHARED_CONFIG_OWNERSHIP = {
11872
12573
  }
11873
12574
  };
11874
12575
  /**
12576
+ * The keys a declaration ensures, added to `document` when it lacks them and
12577
+ * placed in front of every key it has. A document that states all of them —
12578
+ * with whatever value — comes back as it is, and no key of the document
12579
+ * moves: the whole-document writer emits keys in this order, and the JSONC
12580
+ * edit path is told which keys lead (see {@link serializeSharedConfig}).
12581
+ */
12582
+ function withEnsuredKeys({ document, ensuredKeys }) {
12583
+ const missing = Object.entries(ensuredKeys).filter(([key]) => !isPrototypePollutionKey(key) && !Object.hasOwn(document, key));
12584
+ if (missing.length === 0) return document;
12585
+ return {
12586
+ ...Object.fromEntries(missing),
12587
+ ...document
12588
+ };
12589
+ }
12590
+ function serializeDeclaredSharedConfig({ declaration, document, existingContent, filePath, logger }) {
12591
+ const ensuredKeys = declaration.ensuredKeys ?? {};
12592
+ return serializeSharedConfig({
12593
+ format: declaration.format,
12594
+ document: withEnsuredKeys({
12595
+ document,
12596
+ ensuredKeys
12597
+ }),
12598
+ existingContent,
12599
+ leadingKeys: Object.keys(ensuredKeys),
12600
+ filePath,
12601
+ logger
12602
+ });
12603
+ }
12604
+ /**
12605
+ * Serialize a merged document back over a gateway-managed shared file, under
12606
+ * that file's declaration: its format, and the keys the gateway ensures on it
12607
+ * (see {@link SharedConfigFileDeclaration.ensuredKeys}). This is the one
12608
+ * write path of the gateway — {@link applySharedConfigPatch} ends in it, and a
12609
+ * feature whose policy is `custom` serializes its own merge through it — so a
12610
+ * key the gateway ensures is emitted whichever feature writes the file.
12611
+ * Throws when the file is undeclared. A JSONC file that had to be written
12612
+ * whole is warned about through `logger`, named by `filePath` when given and
12613
+ * by `fileKey` otherwise (see {@link serializeSharedConfig}).
12614
+ */
12615
+ function serializeSharedConfigFile({ fileKey, document, existingContent, filePath, logger }) {
12616
+ const declaration = SHARED_CONFIG_OWNERSHIP[fileKey];
12617
+ if (!declaration) throw new Error(`Shared config file '${fileKey}' has no SHARED_CONFIG_OWNERSHIP declaration; declare its writers and policies before writing it through the gateway.`);
12618
+ return serializeDeclaredSharedConfig({
12619
+ declaration,
12620
+ document,
12621
+ existingContent,
12622
+ filePath: filePath ?? fileKey,
12623
+ logger
12624
+ });
12625
+ }
12626
+ /**
11875
12627
  * Execute a feature's declared write to a gateway-managed shared file: parse
11876
12628
  * the existing content, merge the patch under the feature's declared policy,
11877
12629
  * and serialize it back over the existing content (see
11878
- * {@link serializeSharedConfig}, which keeps a JSONC file's comments and
11879
- * formatting outside the spans the merge actually changed). Throws when the
12630
+ * {@link serializeSharedConfigFile}, which adds the keys the file's declaration
12631
+ * ensures and keeps a JSONC file's comments and formatting outside the spans
12632
+ * the merge actually changed). Throws when the
11880
12633
  * file or feature is undeclared, when a
11881
12634
  * `replace-owned-keys` patch strays outside its owned keys, or when the
11882
12635
  * feature's policy is `custom` (those calls go to the named policy function
11883
12636
  * instead).
11884
12637
  */
11885
- function applySharedConfigPatch({ fileKey, feature, existingContent, patch, filePath }) {
12638
+ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, filePath, logger }) {
11886
12639
  const declaration = SHARED_CONFIG_OWNERSHIP[fileKey];
11887
12640
  if (!declaration) throw new Error(`Shared config file '${fileKey}' has no SHARED_CONFIG_OWNERSHIP declaration; declare its writers and policies before writing it through the gateway.`);
11888
12641
  const policy = declaration.features[feature];
@@ -11903,10 +12656,12 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
11903
12656
  patch
11904
12657
  });
11905
12658
  for (const [key, value] of Object.entries(patch)) if (value === void 0) delete document[key];
11906
- return serializeSharedConfig({
11907
- format: declaration.format,
12659
+ return serializeDeclaredSharedConfig({
12660
+ declaration,
11908
12661
  document,
11909
- existingContent
12662
+ existingContent,
12663
+ filePath: filePath ?? fileKey,
12664
+ logger
11910
12665
  });
11911
12666
  }
11912
12667
  const merged = mergeSharedConfigDeep({
@@ -11914,10 +12669,12 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
11914
12669
  patch
11915
12670
  });
11916
12671
  for (const key of policy.replaceKeys ?? []) if (patch[key] !== void 0) merged[key] = sanitizeSharedConfigValue(patch[key]);
11917
- return serializeSharedConfig({
11918
- format: declaration.format,
12672
+ return serializeDeclaredSharedConfig({
12673
+ declaration,
11919
12674
  document: merged,
11920
- existingContent
12675
+ existingContent,
12676
+ filePath: filePath ?? fileKey,
12677
+ logger
11921
12678
  });
11922
12679
  }
11923
12680
  const READ_TOOL_NAME = "Read";
@@ -12461,7 +13218,10 @@ var ChecksProcessor = class extends FeatureProcessor {
12461
13218
  const factory = this.getFactory(this.toolTarget);
12462
13219
  const paths = factory.class.getSettablePaths({ global: this.global });
12463
13220
  const baseDir = join(this.outputRoot, paths.relativeDirPath);
12464
- const checkFilePaths = await findFilesByGlobs(join(baseDir, factory.meta.filePattern));
13221
+ const checkFilePaths = await findFilesByGlobs(factory.meta.filePattern, {
13222
+ cwd: baseDir,
13223
+ followSymbolicLinks: !forDeletion
13224
+ });
12465
13225
  const toRelativeFilePath = (path) => relative(baseDir, path);
12466
13226
  if (forDeletion) {
12467
13227
  const toolChecks = checkFilePaths.map((path) => factory.class.forDeletion({
@@ -13233,7 +13993,7 @@ var AugmentcodeCommand = class AugmentcodeCommand extends ToolCommand {
13233
13993
  */
13234
13994
  static async loadAdditionalImportFiles({ outputRoot = process.cwd(), global = false, logger } = {}) {
13235
13995
  const rootDir = join(outputRoot, AUGMENTCODE_AGENTS_COMMANDS_DIR_PATH);
13236
- const filePaths = await findFilesByGlobs(join(rootDir, "**", "*.md"));
13996
+ const filePaths = await findFilesByGlobs("**/*.md", { cwd: rootDir });
13237
13997
  const imported = (await Promise.all(filePaths.map(async (filePath) => {
13238
13998
  const relativePath = relative(rootDir, filePath);
13239
13999
  if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
@@ -13270,33 +14030,6 @@ var AugmentcodeCommand = class AugmentcodeCommand extends ToolCommand {
13270
14030
  }
13271
14031
  };
13272
14032
  //#endregion
13273
- //#region src/constants/claudecode-paths.ts
13274
- /**
13275
- * Claude Code configuration-layout conventions.
13276
- *
13277
- * Single source of truth for where Claude Code expects its files
13278
- * (directories, file names, scope-specific paths). Every feature module
13279
- * (rules, commands, skills, subagents, ignore, mcp, permissions, hooks)
13280
- * and the gitignore entry registry import from here, so a change in the
13281
- * Claude Code conventions is a change to this file only.
13282
- */
13283
- /** Root directory for Claude Code configuration, relative to the scope root. */
13284
- const CLAUDECODE_DIR = ".claude";
13285
- const CLAUDECODE_RULE_FILE_NAME = "CLAUDE.md";
13286
- const CLAUDECODE_LOCAL_RULE_FILE_NAME = "CLAUDE.local.md";
13287
- /** Modular rules directory name under `.claude/` (current format). */
13288
- const CLAUDECODE_RULES_DIR_NAME = "rules";
13289
- /** Memories directory name under `.claude/` (legacy format). */
13290
- const CLAUDECODE_MEMORIES_DIR_NAME = "memories";
13291
- const CLAUDECODE_COMMANDS_DIR_PATH = join(CLAUDECODE_DIR, "commands");
13292
- const CLAUDECODE_AGENTS_DIR_PATH = join(CLAUDECODE_DIR, "agents");
13293
- const CLAUDECODE_SKILLS_DIR_PATH = join(CLAUDECODE_DIR, "skills");
13294
- const CLAUDECODE_SCHEDULED_TASKS_DIR_PATH = join(CLAUDECODE_DIR, "scheduled-tasks");
13295
- const CLAUDECODE_SETTINGS_FILE_NAME = "settings.json";
13296
- const CLAUDECODE_SETTINGS_LOCAL_FILE_NAME = "settings.local.json";
13297
- const CLAUDECODE_MCP_FILE_NAME = ".mcp.json";
13298
- const CLAUDECODE_GLOBAL_MCP_FILE_NAME = ".claude.json";
13299
- //#endregion
13300
14033
  //#region src/features/commands/claudecode-command.ts
13301
14034
  const ClaudecodeCommandFrontmatterSchema = z.looseObject({
13302
14035
  description: z.optional(z.string()),
@@ -13944,7 +14677,7 @@ function commandSlug(relativeFilePath) {
13944
14677
  * it up as a regular orphan.
13945
14678
  */
13946
14679
  async function rulesyncCommandSlugExists({ inputRoots, dirName }) {
13947
- return (await Promise.all(inputRoots.map((root) => findFilesByGlobs(join(root, COMMANDS_FEATURE_SUBDIR, "**", "*.md"))))).flat().some((filePath) => commandSlug(basename(filePath)) === dirName);
14680
+ return (await Promise.all(inputRoots.map((root) => findFilesByGlobs("**/*.md", { cwd: join(root, COMMANDS_FEATURE_SUBDIR) })))).flat().some((filePath) => commandSlug(basename(filePath)) === dirName);
13948
14681
  }
13949
14682
  //#endregion
13950
14683
  //#region src/features/commands/devin-command.ts
@@ -14907,6 +15640,41 @@ function toStringMetadata(metadata) {
14907
15640
  return Object.fromEntries(Object.entries(metadata).map(([key, value]) => [key, stringifyValue(value)]));
14908
15641
  }
14909
15642
  /**
15643
+ * The `agentsskills` block with the three Agent Skills standard fields resolved
15644
+ * against the root-level rulesync defaults (a defined section value wins).
15645
+ *
15646
+ * Every writer of an Agent Skills `SKILL.md` — the native target, Hermes Agent
15647
+ * and the simulated `agentsmd` — reads the block through this helper so a
15648
+ * root-authored value goes through exactly the same normalization and
15649
+ * spec-violation reporting as a section value. `allowed-tools` has no root
15650
+ * counterpart and is carried through untouched.
15651
+ *
15652
+ * @returns The merged block. It is empty when neither the section nor any
15653
+ * root-level field is set, which `toSpecConformantAgentSkillFields` emits as
15654
+ * nothing.
15655
+ */
15656
+ function resolveAgentsSkillsSection(rulesyncFrontmatter) {
15657
+ const section = rulesyncFrontmatter.agentsskills;
15658
+ const license = resolveLicense({
15659
+ rootFrontmatter: rulesyncFrontmatter,
15660
+ section
15661
+ });
15662
+ const compatibility = resolveCompatibility({
15663
+ rootFrontmatter: rulesyncFrontmatter,
15664
+ section
15665
+ });
15666
+ const metadata = resolveMetadata({
15667
+ rootFrontmatter: rulesyncFrontmatter,
15668
+ section
15669
+ });
15670
+ return {
15671
+ ...section,
15672
+ ...license !== void 0 && { license },
15673
+ ...compatibility !== void 0 && { compatibility },
15674
+ ...metadata !== void 0 && { metadata }
15675
+ };
15676
+ }
15677
+ /**
14910
15678
  * Convert the rulesync `agentsskills` block into the shapes the specification
14911
15679
  * requires. Shared with `HermesagentSkill`, which writes the same fields to its
14912
15680
  * own skill location, so one rulesync input can never produce two different
@@ -15054,7 +15822,7 @@ var AgentsSkillsSkill = class AgentsSkillsSkill extends ToolSkill {
15054
15822
  const agentsSkillsFrontmatter = {
15055
15823
  name: rulesyncFrontmatter.name,
15056
15824
  description: rulesyncFrontmatter.description,
15057
- ...toSpecConformantAgentSkillFields(rulesyncFrontmatter.agentsskills)
15825
+ ...toSpecConformantAgentSkillFields(resolveAgentsSkillsSection(rulesyncFrontmatter))
15058
15826
  };
15059
15827
  AgentsSkillsSkill.reportSpecViolations({
15060
15828
  outputRoot,
@@ -15162,7 +15930,7 @@ var HermesagentSkill = class HermesagentSkill extends AgentsSkillsSkill {
15162
15930
  }
15163
15931
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false, logger }) {
15164
15932
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
15165
- const shared = toSpecConformantAgentSkillFields(rulesyncFrontmatter.agentsskills, { coerceMetadata: false });
15933
+ const shared = toSpecConformantAgentSkillFields(resolveAgentsSkillsSection(rulesyncFrontmatter), { coerceMetadata: false });
15166
15934
  const hermes = rulesyncFrontmatter.hermesagent ?? {};
15167
15935
  const dirName = rulesyncSkill.getDirName();
15168
15936
  const frontmatter = {
@@ -17808,7 +18576,7 @@ var CommandsProcessor = class extends FeatureProcessor {
17808
18576
  const treeName = basename(sourceTree);
17809
18577
  const treeCommandsDirPath = join(treeName, COMMANDS_FEATURE_SUBDIR);
17810
18578
  const basePath = join(sourceTree, COMMANDS_FEATURE_SUBDIR);
17811
- const rulesyncCommandPaths = await directoryExistsStrict(basePath) ? await findFilesByGlobs(join(basePath, "**", "*.md")) : [];
18579
+ const rulesyncCommandPaths = await directoryExistsStrict(basePath) ? await findFilesByGlobs("**/*.md", { cwd: basePath }) : [];
17812
18580
  return await Promise.all(rulesyncCommandPaths.map((path) => RulesyncCommand.fromFile({
17813
18581
  outputRoot: treeParent,
17814
18582
  relativeDirPath: treeCommandsDirPath,
@@ -17841,7 +18609,10 @@ var CommandsProcessor = class extends FeatureProcessor {
17841
18609
  if (factory.meta.skipToolFileScan) return [];
17842
18610
  const paths = factory.class.getSettablePaths({ global: this.global });
17843
18611
  const outputRootFull = join(this.outputRoot, paths.relativeDirPath);
17844
- const commandFilePaths = await findFilesByGlobs(factory.meta.supportsSubdirectory ? join(outputRootFull, "**", `*.${factory.meta.extension}`) : join(outputRootFull, `*.${factory.meta.extension}`), { followSymbolicLinks: !forDeletion });
18612
+ const commandFilePaths = await findFilesByGlobs(factory.meta.supportsSubdirectory ? `**/*.${factory.meta.extension}` : `*.${factory.meta.extension}`, {
18613
+ cwd: outputRootFull,
18614
+ followSymbolicLinks: !forDeletion
18615
+ });
17845
18616
  if (forDeletion) {
17846
18617
  const toolCommands = commandFilePaths.map((path) => factory.class.forDeletion({
17847
18618
  outputRoot: this.outputRoot,
@@ -18190,52 +18961,6 @@ function compact(obj) {
18190
18961
  return result;
18191
18962
  }
18192
18963
  //#endregion
18193
- //#region src/utils/quote-value.ts
18194
- /**
18195
- * How much of a value read off disk a diagnostic quotes.
18196
- *
18197
- * Enough to recognize which entry is meant, and no more. A warning names the
18198
- * offending value so the reader can find it, but the values these warnings
18199
- * quote come from files rulesync did not write — a tool's own settings, a
18200
- * machine-local overrides file, a repository fetched from elsewhere — and they
18201
- * no longer stop at a terminal: they travel into a `--json` document another
18202
- * program parses and into an MCP result an agent reads as context. A command
18203
- * line or a header is the shape most likely to carry a credential, and a long
18204
- * value is the shape most likely to carry instructions aimed at the agent.
18205
- */
18206
- const MAX_QUOTED_VALUE_LENGTH = 60;
18207
- /**
18208
- * A short, quotable rendering of a value for a diagnostic.
18209
- *
18210
- * Serialized rather than interpolated, because an unquoted value is what lets a
18211
- * crafted one read as a second line; stripped of the control characters
18212
- * `JSON.stringify` leaves intact (it escapes C0 only, not the C1 range or the
18213
- * bidirectional overrides); and truncated.
18214
- */
18215
- function quoteValueForWarning(value) {
18216
- return truncateText({
18217
- text: stripControlCharacters(serialize(value)),
18218
- maxLength: MAX_QUOTED_VALUE_LENGTH,
18219
- suffix: "…(truncated)"
18220
- });
18221
- }
18222
- function serialize(value) {
18223
- try {
18224
- return JSON.stringify(value, stripStrings) ?? String(value);
18225
- } catch {
18226
- return `[unserializable ${typeof value}]`;
18227
- }
18228
- }
18229
- /**
18230
- * Strip the control characters out of every string before `JSON.stringify`
18231
- * sees it, not only out of the document it produces: `JSON.stringify` escapes
18232
- * a C0 character into the six literal characters `\u001b`, which no later pass
18233
- * over the output can recognize as a control character again.
18234
- */
18235
- function stripStrings(_key, value) {
18236
- return typeof value === "string" ? stripControlCharacters(value) : value;
18237
- }
18238
- //#endregion
18239
18964
  //#region src/features/hooks/tool-hooks-converter.ts
18240
18965
  function isToolMatcherEntry(x) {
18241
18966
  if (x === null || typeof x !== "object") return false;
@@ -24822,6 +25547,17 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
24822
25547
  };
24823
25548
  }
24824
25549
  /**
25550
+ * The `fileMode: "local"` twin, `.claude/settings.local.json`: a settable
25551
+ * path only under that option, so the default `getSettablePaths` does not
25552
+ * report it, and the gateway would otherwise not know the file has a writer.
25553
+ */
25554
+ static getExtraSharedWritePaths() {
25555
+ return [{
25556
+ relativeDirPath: CLAUDECODE_DIR,
25557
+ relativeFilePath: CLAUDECODE_SETTINGS_LOCAL_FILE_NAME
25558
+ }];
25559
+ }
25560
+ /**
24825
25561
  * ClaudecodeIgnore uses settings.json (or settings.local.json), which can
24826
25562
  * include non-ignore settings. It should not be deleted by rulesync.
24827
25563
  *
@@ -24864,7 +25600,11 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
24864
25600
  outputRoot,
24865
25601
  relativeDirPath: paths.relativeDirPath,
24866
25602
  relativeFilePath: paths.relativeFilePath,
24867
- fileContent: JSON.stringify(jsonValue, null, 2),
25603
+ fileContent: serializeSharedConfigFile({
25604
+ fileKey: sharedConfigFileKey(paths),
25605
+ document: jsonValue,
25606
+ existingContent: existingFileContent
25607
+ }),
24868
25608
  validate: true
24869
25609
  });
24870
25610
  }
@@ -26240,18 +26980,48 @@ var AiassistantMcp = class AiassistantMcp extends ToolMcp {
26240
26980
  }
26241
26981
  };
26242
26982
  //#endregion
26243
- //#region src/features/mcp/amp-mcp.ts
26244
- const AMP_MCP_SERVERS_KEY = "amp.mcpServers";
26245
- function parseAmpSettingsJsonc(fileContent) {
26246
- const errors = [];
26247
- const parsed = parse(fileContent || "{}", errors, { allowTrailingComma: true });
26248
- if (errors.length > 0) {
26249
- const details = errors.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`).join(", ");
26250
- throw new Error(`Failed to parse Amp settings: ${details}`);
26983
+ //#region src/features/shared/amp-settings.ts
26984
+ /**
26985
+ * Parse an Amp `settings.json` / `settings.jsonc` document.
26986
+ *
26987
+ * Shared by the MCP and permissions adapters because both read the very same
26988
+ * file. Two separately maintained copies drifted once already: the permissions
26989
+ * side was hardened while the MCP side kept calling `jsonc-parser` directly,
26990
+ * which left it importing MCP servers reachable only through an injected
26991
+ * `__proto__`.
26992
+ *
26993
+ * {@link parseJsonc} is the strict, sanitizing parser: it throws on any syntax
26994
+ * error instead of returning `jsonc-parser`'s best-effort value, and it
26995
+ * rebuilds every object from its own enumerable entries, so a root `__proto__`
26996
+ * cannot swap the returned object's prototype and `constructor` / `prototype`
26997
+ * never survive as own keys, at any depth.
26998
+ *
26999
+ * Removal is silent, matching every other tool-side adapter (opencode, kilo,
27000
+ * copilot). Reporting the removed keys through `droppedPollutionKeysError` is
27001
+ * reserved for the three files a user authors under `.rulesync/`, whose whole
27002
+ * purpose is to be turned into tool config — a key that vanishes there needs
27003
+ * explaining, whereas here the surrounding settings are the user's own file
27004
+ * and are left alone.
27005
+ *
27006
+ * Note the consequence for a root `__proto__`: sanitizing runs before the
27007
+ * plain-object check, so such a document parses successfully with the key
27008
+ * stripped rather than failing that check. That is deliberate — dropping the
27009
+ * one poisoned key keeps the user's unrelated settings — and matches the
27010
+ * sanitize-before-the-root-check design the shared config gateway documents.
27011
+ */
27012
+ function parseAmpSettings({ fileContent }) {
27013
+ let parsed;
27014
+ try {
27015
+ parsed = parseJsonc(fileContent || "{}");
27016
+ } catch (error) {
27017
+ throw new Error(`Failed to parse Amp settings: ${formatError(error)}`, { cause: error });
26251
27018
  }
26252
27019
  if (!isPlainObject$1(parsed)) throw new Error("Amp settings must be a JSON object");
26253
27020
  return parsed;
26254
27021
  }
27022
+ //#endregion
27023
+ //#region src/features/mcp/amp-mcp.ts
27024
+ const AMP_MCP_SERVERS_KEY = "amp.mcpServers";
26255
27025
  function filterMcpServers(mcpServers) {
26256
27026
  const filtered = {};
26257
27027
  if (!isRecord$1(mcpServers)) return filtered;
@@ -26270,7 +27040,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26270
27040
  json;
26271
27041
  constructor(params) {
26272
27042
  super(params);
26273
- this.json = parseAmpSettingsJsonc(this.fileContent);
27043
+ this.json = parseAmpSettings({ fileContent: this.fileContent });
26274
27044
  }
26275
27045
  getJson() {
26276
27046
  return structuredClone(this.json);
@@ -26314,7 +27084,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26314
27084
  const basePaths = this.getSettablePaths({ global });
26315
27085
  const jsonDir = join(outputRoot, basePaths.relativeDirPath);
26316
27086
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
26317
- const json = fileContent ? parseAmpSettingsJsonc(fileContent) : {};
27087
+ const json = fileContent ? parseAmpSettings({ fileContent }) : {};
26318
27088
  const mcpServers = json[AMP_MCP_SERVERS_KEY];
26319
27089
  const newJson = {
26320
27090
  ...json,
@@ -26329,7 +27099,7 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26329
27099
  global
26330
27100
  });
26331
27101
  }
26332
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
27102
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
26333
27103
  const basePaths = this.getSettablePaths({ global });
26334
27104
  const jsonDir = join(outputRoot, basePaths.relativeDirPath);
26335
27105
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
@@ -26342,7 +27112,8 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26342
27112
  feature: "mcp",
26343
27113
  existingContent: fileContent ?? "",
26344
27114
  patch: { [AMP_MCP_SERVERS_KEY]: filterMcpServers(rulesyncMcp.getMcpServers()) },
26345
- filePath: join(jsonDir, relativeFilePath)
27115
+ filePath: join(jsonDir, relativeFilePath),
27116
+ logger
26346
27117
  }),
26347
27118
  validate,
26348
27119
  global
@@ -26355,17 +27126,13 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26355
27126
  validate() {
26356
27127
  let json;
26357
27128
  try {
26358
- json = parseAmpSettingsJsonc(this.fileContent);
27129
+ json = parseAmpSettings({ fileContent: this.fileContent });
26359
27130
  } catch (error) {
26360
27131
  return {
26361
27132
  success: false,
26362
27133
  error: error instanceof Error ? error : new Error(String(error))
26363
27134
  };
26364
27135
  }
26365
- for (const key of Object.keys(json)) if (isPrototypePollutionKey(key)) return {
26366
- success: false,
26367
- error: /* @__PURE__ */ new Error(`Prototype pollution key "${key}" is not allowed`)
26368
- };
26369
27136
  const mcpServers = json[AMP_MCP_SERVERS_KEY];
26370
27137
  if (mcpServers === void 0) return {
26371
27138
  success: true,
@@ -26375,20 +27142,10 @@ var AmpMcp = class AmpMcp extends ToolMcp {
26375
27142
  success: false,
26376
27143
  error: /* @__PURE__ */ new Error(`${AMP_MCP_SERVERS_KEY} must be a JSON object`)
26377
27144
  };
26378
- for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
26379
- if (isPrototypePollutionKey(serverName)) return {
26380
- success: false,
26381
- error: /* @__PURE__ */ new Error(`Server name "${serverName}" is a prototype pollution key and is not allowed`)
26382
- };
26383
- if (!isRecord$1(serverConfig)) return {
26384
- success: false,
26385
- error: /* @__PURE__ */ new Error(`MCP server "${serverName}" must be a JSON object`)
26386
- };
26387
- for (const key of Object.keys(serverConfig)) if (isPrototypePollutionKey(key)) return {
26388
- success: false,
26389
- error: /* @__PURE__ */ new Error(`Config key "${key}" in server "${serverName}" is a prototype pollution key and is not allowed`)
26390
- };
26391
- }
27145
+ for (const [serverName, serverConfig] of Object.entries(mcpServers)) if (!isRecord$1(serverConfig)) return {
27146
+ success: false,
27147
+ error: /* @__PURE__ */ new Error(`MCP server "${serverName}" must be a JSON object`)
27148
+ };
26392
27149
  return {
26393
27150
  success: true,
26394
27151
  error: null
@@ -27340,7 +28097,7 @@ var CopilotMcp = class CopilotMcp extends ToolMcp {
27340
28097
  validate
27341
28098
  });
27342
28099
  }
27343
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true }) {
28100
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, logger }) {
27344
28101
  const paths = this.getSettablePaths();
27345
28102
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
27346
28103
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
@@ -27353,7 +28110,8 @@ var CopilotMcp = class CopilotMcp extends ToolMcp {
27353
28110
  feature: "mcp",
27354
28111
  existingContent,
27355
28112
  patch: { servers: rulesyncMcp.getMcpServers() },
27356
- filePath
28113
+ filePath,
28114
+ logger
27357
28115
  }),
27358
28116
  validate
27359
28117
  });
@@ -29484,7 +30242,8 @@ var KiloMcp = class KiloMcp extends ToolMcp {
29484
30242
  mcp: convertedMcp,
29485
30243
  tools: Object.keys(mcpTools).length > 0 ? mcpTools : void 0
29486
30244
  },
29487
- filePath: join(jsonDir, relativeFilePath)
30245
+ filePath: join(jsonDir, relativeFilePath),
30246
+ logger
29488
30247
  }),
29489
30248
  validate
29490
30249
  });
@@ -29502,7 +30261,7 @@ var KiloMcp = class KiloMcp extends ToolMcp {
29502
30261
  *
29503
30262
  * @see https://kilo.ai/docs/automate/mcp/using-in-kilo-code
29504
30263
  */
29505
- static async fromInstructions({ outputRoot = process.cwd(), instructions, validate = true, global = false }) {
30264
+ static async fromInstructions({ outputRoot = process.cwd(), instructions, validate = true, global = false, logger }) {
29506
30265
  const basePaths = this.getSettablePaths({ global });
29507
30266
  const jsonDir = join(outputRoot, basePaths.relativeDirPath);
29508
30267
  let fileContent = null;
@@ -29529,7 +30288,8 @@ var KiloMcp = class KiloMcp extends ToolMcp {
29529
30288
  feature: "rules",
29530
30289
  existingContent: fileContent ?? "",
29531
30290
  patch: { instructions: mergedInstructions.length > 0 ? mergedInstructions : void 0 },
29532
- filePath: join(jsonDir, relativeFilePath)
30291
+ filePath: join(jsonDir, relativeFilePath),
30292
+ logger
29533
30293
  }),
29534
30294
  validate
29535
30295
  });
@@ -30549,7 +31309,8 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
30549
31309
  mcp: convertedMcp,
30550
31310
  tools: Object.keys(mcpTools).length > 0 ? mcpTools : void 0
30551
31311
  },
30552
- filePath: join(jsonDir, relativeFilePath)
31312
+ filePath: join(jsonDir, relativeFilePath),
31313
+ logger
30553
31314
  }),
30554
31315
  validate
30555
31316
  });
@@ -30570,7 +31331,7 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
30570
31331
  * @see https://opencode.ai/docs/rules/
30571
31332
  * @see https://opencode.ai/docs/config/
30572
31333
  */
30573
- static async fromInstructions({ outputRoot = process.cwd(), instructions, validate = true, global = false }) {
31334
+ static async fromInstructions({ outputRoot = process.cwd(), instructions, validate = true, global = false, logger }) {
30574
31335
  const basePaths = this.getSettablePaths({ global });
30575
31336
  const jsonDir = join(outputRoot, basePaths.relativeDirPath);
30576
31337
  const configDirPrefix = `${toPosixPath(basePaths.relativeDirPath).replace(/\/+$/, "")}/`;
@@ -30602,7 +31363,8 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
30602
31363
  feature: "rules",
30603
31364
  existingContent: fileContent ?? "",
30604
31365
  patch: { instructions: mergedInstructions.length > 0 ? mergedInstructions : void 0 },
30605
- filePath: join(jsonDir, relativeFilePath)
31366
+ filePath: join(jsonDir, relativeFilePath),
31367
+ logger
30606
31368
  }),
30607
31369
  validate
30608
31370
  });
@@ -33090,16 +33852,6 @@ function isCanonicalAmpEntry(entry) {
33090
33852
  const keys = Object.keys(matches);
33091
33853
  return keys.length === 0 || keys.length === 1 && typeof matches.cmd === "string";
33092
33854
  }
33093
- function parseAmpSettings(fileContent) {
33094
- const errors = [];
33095
- const parsed = parse(fileContent || "{}", errors, { allowTrailingComma: true });
33096
- if (errors.length > 0) {
33097
- const details = errors.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`).join(", ");
33098
- throw new Error(`Failed to parse Amp settings: ${details}`);
33099
- }
33100
- if (!isPlainObject$1(parsed)) throw new Error("Amp settings must be a JSON object");
33101
- return parsed;
33102
- }
33103
33855
  function toDisableList(value) {
33104
33856
  if (!Array.isArray(value)) return [];
33105
33857
  return value.filter((entry) => typeof entry === "string");
@@ -33215,7 +33967,7 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
33215
33967
  const basePaths = AmpPermissions.getSettablePaths({ global });
33216
33968
  const jsonDir = join(outputRoot, basePaths.relativeDirPath);
33217
33969
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
33218
- const json = fileContent ? parseAmpSettings(fileContent) : {};
33970
+ const json = fileContent ? parseAmpSettings({ fileContent }) : {};
33219
33971
  const newJson = {
33220
33972
  ...json,
33221
33973
  [AMP_TOOLS_DISABLE_KEY]: toDisableList(json[AMP_TOOLS_DISABLE_KEY])
@@ -33228,11 +33980,11 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
33228
33980
  validate
33229
33981
  });
33230
33982
  }
33231
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false }) {
33983
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, logger }) {
33232
33984
  const basePaths = AmpPermissions.getSettablePaths({ global });
33233
33985
  const jsonDir = join(outputRoot, basePaths.relativeDirPath);
33234
33986
  const { fileContent, relativeFilePath } = await this.resolveSettingsFile(jsonDir);
33235
- const json = fileContent ? parseAmpSettings(fileContent) : {};
33987
+ const json = fileContent ? parseAmpSettings({ fileContent }) : {};
33236
33988
  const config = rulesyncPermissions.getJson();
33237
33989
  const { disable, permissions } = convertRulesyncToAmp(config);
33238
33990
  const override = config.amp;
@@ -33259,13 +34011,14 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
33259
34011
  feature: "permissions",
33260
34012
  existingContent: fileContent ?? "",
33261
34013
  patch,
33262
- filePath: join(jsonDir, relativeFilePath)
34014
+ filePath: join(jsonDir, relativeFilePath),
34015
+ logger
33263
34016
  }),
33264
34017
  validate: true
33265
34018
  });
33266
34019
  }
33267
34020
  toRulesyncPermissions() {
33268
- const json = parseAmpSettings(this.getFileContent());
34021
+ const json = parseAmpSettings({ fileContent: this.getFileContent() });
33269
34022
  const allPermissions = toPermissionsList(json[AMP_PERMISSIONS_KEY]);
33270
34023
  const canonicalEntries = allPermissions.filter(isCanonicalAmpEntry);
33271
34024
  const overrideEntries = allPermissions.filter((entry) => !isCanonicalAmpEntry(entry));
@@ -33285,7 +34038,7 @@ var AmpPermissions = class AmpPermissions extends ToolPermissions {
33285
34038
  }
33286
34039
  validate() {
33287
34040
  try {
33288
- const json = parseAmpSettings(this.fileContent);
34041
+ const json = parseAmpSettings({ fileContent: this.fileContent });
33289
34042
  const disable = json[AMP_TOOLS_DISABLE_KEY];
33290
34043
  if (disable !== void 0 && !Array.isArray(disable)) return {
33291
34044
  success: false,
@@ -35335,15 +36088,15 @@ const CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS = Object.entries(SHARED_CONFIG_OWNE
35335
36088
  * passthrough must not carry. `permissions` and `sandbox` have their own merge
35336
36089
  * branches (the managed `allow`/`ask`/`deny` arrays and the scope filtering
35337
36090
  * respectively), `permission` is rulesync's own canonical tool-scoped block
35338
- * rather than a settings key, `$schema` is an editor pointer rather than a
35339
- * Claude Code setting, and the rest belong to the other features writing this
35340
- * shared file.
36091
+ * rather than a settings key, the keys the gateway ensures on the file
36092
+ * (`$schema`) are editor pointers rather than Claude Code settings, and the
36093
+ * rest belong to the other features writing this shared file.
35341
36094
  */
35342
36095
  const CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
35343
36096
  "permission",
35344
36097
  "permissions",
35345
36098
  "sandbox",
35346
- "$schema",
36099
+ ...Object.keys(SHARED_CONFIG_OWNERSHIP[".claude/settings.json"]?.ensuredKeys ?? {}),
35347
36100
  ...CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS
35348
36101
  ]);
35349
36102
  /**
@@ -35715,7 +36468,11 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
35715
36468
  deny,
35716
36469
  logger
35717
36470
  });
35718
- const fileContent = JSON.stringify(merged, null, 2);
36471
+ const fileContent = serializeSharedConfigFile({
36472
+ fileKey: CLAUDE_SETTINGS_SHARED_FILE_KEY,
36473
+ document: merged,
36474
+ existingContent
36475
+ });
35719
36476
  return new ClaudecodePermissions({
35720
36477
  outputRoot,
35721
36478
  relativeDirPath: paths.relativeDirPath,
@@ -36742,7 +37499,7 @@ var CopilotPermissions = class CopilotPermissions extends ToolPermissions {
36742
37499
  validate
36743
37500
  });
36744
37501
  }
36745
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions }) {
37502
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger }) {
36746
37503
  const paths = CopilotPermissions.getSettablePaths();
36747
37504
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
36748
37505
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
@@ -36762,7 +37519,8 @@ var CopilotPermissions = class CopilotPermissions extends ToolPermissions {
36762
37519
  feature: "permissions",
36763
37520
  existingContent,
36764
37521
  patch,
36765
- filePath
37522
+ filePath,
37523
+ logger
36766
37524
  }),
36767
37525
  validate: true
36768
37526
  });
@@ -37482,6 +38240,18 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
37482
38240
  isDeletable() {
37483
38241
  return false;
37484
38242
  }
38243
+ /**
38244
+ * `config.toml` is dcode's file, not one rulesync owns: rulesync merges into
38245
+ * it when it exists but has no business bringing it into existence to hold
38246
+ * nothing. When no rule maps, `[shell]` is dropped and
38247
+ * `smolToml.stringify({})` leaves a lone newline, which would otherwise be
38248
+ * written as a fresh `~/.deepagents/config.toml` that says nothing. An
38249
+ * existing file is still rewritten as before, so user content is never
38250
+ * dropped — the skip only applies when there is no file yet.
38251
+ */
38252
+ shouldSkipCreationWhenPayloadEmpty() {
38253
+ return true;
38254
+ }
37485
38255
  static getSettablePaths(_options) {
37486
38256
  return {
37487
38257
  relativeDirPath: DEEPAGENTS_DIR,
@@ -40414,7 +41184,8 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
40414
41184
  feature: "permissions",
40415
41185
  existingContent: fileContent ?? "",
40416
41186
  patch: { permission },
40417
- filePath: join(jsonDir, relativeFilePath)
41187
+ filePath: join(jsonDir, relativeFilePath),
41188
+ logger
40418
41189
  }),
40419
41190
  validate: true
40420
41191
  });
@@ -42539,18 +43310,20 @@ function pickSecurityPolicies(source, report) {
42539
43310
  * not emoji at all are named beside it: the angle brackets, the trigrams and
42540
43311
  * digrams, and the hexagrams of U+4DC0–U+4DFF.
42541
43312
  *
42542
- * The ranges are the standard ones rather than a lookup of the full property
42543
- * table, which Unicode revises with every release and which is not worth
42544
- * carrying for the one thing it is used for here — deciding when a label is
42545
- * long enough to wrap. Where a range is coarse it is coarse in the safe
42546
- * direction: the whole of U+1F000–U+1FAFF is counted as wide, which overstates
42547
- * the few narrow symbols in it, because overstating a width shortens a label
42548
- * that did not need it while understating one lets a label wrap. That is also
42549
- * why an emoji built from a chain of joiners is counted per component: two
42550
- * columns each rather than the two the whole chain draws.
42551
- *
42552
- * Ambiguous-width characters are counted as one column, which is what a
42553
- * terminal running a Latin font does.
43313
+ * The ranges are the standard blocks rather than the full `East_Asian_Width`
43314
+ * property, because the wide characters sit in blocks and a block is coarse in
43315
+ * the safe direction: the whole of U+1F000–U+1FAFF is counted as wide, which
43316
+ * overstates the few narrow symbols in it, because overstating a width shortens
43317
+ * a label that did not need it while understating one lets a label wrap. That
43318
+ * is also why an emoji built from a chain of joiners is counted per component:
43319
+ * two columns each rather than the two the whole chain draws.
43320
+ *
43321
+ * The East Asian Ambiguous class is counted at two columns as well, by the
43322
+ * table below this one rather than by these ranges, and that table is the
43323
+ * whole of its property: the class is scattered among the Neutral characters
43324
+ * rather than gathered in blocks, so a block list over it would either miss
43325
+ * members or take in characters that are not wide anywhere. The reason for
43326
+ * counting it, and how the table is kept in step with Unicode, is given there.
42554
43327
  *
42555
43328
  * Two of the ranges are here for a narrower reason: a name the skill prompt can
42556
43329
  * offer may not be counted narrower here than the prompt's own renderer counts
@@ -42588,6 +43361,64 @@ function pickSecurityPolicies(source, report) {
42588
43361
  */
42589
43362
  const WIDE_CHARACTERS_PATTERN = /\p{Emoji_Presentation}|\p{Emoji_Modifier_Base}|[\u2329\u232a\u2630-\u2637\u268a-\u268f\u4dc0-\u4dff]|[\u1100-\u11ff\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\ua960-\ua97f\uac00-\ud7a3\ud7b0-\ud7fb\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]|[\u{16fe0}-\u{16ff6}]|[\u{17000}-\u{18dff}]|[\u{1aff0}-\u{1b2ff}]|[\u{1f000}-\u{1faff}]|[\u{20000}-\u{3fffd}]/u;
42590
43363
  /**
43364
+ * The East Asian Ambiguous class of UAX #11: the characters a terminal draws
43365
+ * one column wide under a Latin font and two columns wide where it is set to
43366
+ * draw the ambiguous class wide, which is a common setting in CJK locales. The
43367
+ * box-drawing characters, the geometric shapes, the Greek and Cyrillic
43368
+ * alphabets, and the accented letters of the Latin-1 supplement are all here.
43369
+ *
43370
+ * Counted at two columns, which is the wider of the two answers, because the
43371
+ * two ways of being wrong are not the same size. Overstating a width shortens
43372
+ * a label that did not need it: a name carrying a Greek letter or an accented
43373
+ * letter is cut a little earlier on a Latin terminal than it had to be.
43374
+ * Understating one lets a forged row wrap: a name of sixty box-drawing
43375
+ * characters and `● pdf-tools` measures 71 columns at one column apiece,
43376
+ * inside the prompt's budget, and draws at 132 in a terminal that draws the
43377
+ * class wide, so the terminal breaks the row itself and puts `● pdf-tools` at
43378
+ * the left margin of a continuation line that carries no pointer and no
43379
+ * checkbox.
43380
+ *
43381
+ * The prompt's own renderer, `fast-string-width`, counts the class at one
43382
+ * column and never sees the wrap coming either; the terminal is what breaks
43383
+ * the row, and only the budget can keep the row short enough not to be broken.
43384
+ *
43385
+ * One width model rather than two — a wide one for bounding an untrusted name
43386
+ * and a narrow one for laying out the tool's own text — because the second
43387
+ * model would buy a column or two of alignment in the tool's own output at the
43388
+ * price of two measurements that have to be kept from being confused for each
43389
+ * other. Where this is used to lay out text of the tool's own, the cost of the
43390
+ * single model is a line cut a little earlier than it needed to be.
43391
+ *
43392
+ * The ranges are the `East_Asian_Width=A` property of Unicode 17.0, taken from
43393
+ * the table `get-east-asian-width` 1.6.0 carries and merged where two are
43394
+ * adjacent. The table is written out here rather than read from the package so
43395
+ * that the one lookup adds no dependency of its own, and the drift test in
43396
+ * `display-width.test.ts` is what keeps it honest: it loads the package through
43397
+ * the prompt renderer that already depends on it, walks every code point, and
43398
+ * fails when this table and the property disagree in either direction. When
43399
+ * the renderer's copy moves to a newer Unicode, that test is what fails, and
43400
+ * the table is regenerated from the new property — its ranges, marks aside,
43401
+ * merged where adjacent and written as escapes — with the version named here.
43402
+ *
43403
+ * Three stretches of the property are left out because they never reach this
43404
+ * pattern: the combining diacritical marks of U+0300–U+036F and the variation
43405
+ * selectors of U+FE00–U+FE0F and U+E0100–U+E01EF are marks by category and are
43406
+ * counted by the mark rule before the width of a character is looked up.
43407
+ * Leaving them out is also what keeps the class from holding a combining
43408
+ * character, which `no-misleading-character-class` is there to catch, and they
43409
+ * are the one exception the drift test allows. U+00AD SOFT HYPHEN never reaches
43410
+ * this pattern either — it is a format character, and the zero-width rule
43411
+ * counts it at nothing first — but it is kept, as the property has it, so that
43412
+ * the test holds the table to the property exactly rather than to a list of
43413
+ * exceptions. The private use areas are in it, as the property says they are: a
43414
+ * name is free to carry them and a terminal is free to draw them wide.
43415
+ *
43416
+ * Escapes rather than the characters themselves, for the reason given above.
43417
+ * Exported for the drift test alone; the width of a string is asked for
43418
+ * through `displayWidthOf`.
43419
+ */
43420
+ const AMBIGUOUS_CHARACTERS_PATTERN = /[\u00a1\u00a4\u00a7-\u00a8\u00aa\u00ad-\u00ae\u00b0-\u00b4\u00b6-\u00ba\u00bc-\u00bf\u00c6\u00d0\u00d7-\u00d8\u00de-\u00e1\u00e6\u00e8-\u00ea\u00ec-\u00ed\u00f0\u00f2-\u00f3\u00f7-\u00fa\u00fc\u00fe\u0101\u0111\u0113\u011b\u0126-\u0127\u012b\u0131-\u0133\u0138\u013f-\u0142\u0144\u0148-\u014b\u014d\u0152-\u0153\u0166-\u0167\u016b\u01ce\u01d0\u01d2\u01d4\u01d6\u01d8\u01da\u01dc\u0251\u0261\u02c4\u02c7\u02c9-\u02cb\u02cd\u02d0\u02d8-\u02db\u02dd\u02df\u0391-\u03a1\u03a3-\u03a9\u03b1-\u03c1\u03c3-\u03c9\u0401\u0410-\u044f\u0451\u2010\u2013-\u2016\u2018-\u2019\u201c-\u201d\u2020-\u2022\u2024-\u2027\u2030\u2032-\u2033\u2035\u203b\u203e\u2074\u207f\u2081-\u2084\u20ac\u2103\u2105\u2109\u2113\u2116\u2121-\u2122\u2126\u212b\u2153-\u2154\u215b-\u215e\u2160-\u216b\u2170-\u2179\u2189\u2190-\u2199\u21b8-\u21b9\u21d2\u21d4\u21e7\u2200\u2202-\u2203\u2207-\u2208\u220b\u220f\u2211\u2215\u221a\u221d-\u2220\u2223\u2225\u2227-\u222c\u222e\u2234-\u2237\u223c-\u223d\u2248\u224c\u2252\u2260-\u2261\u2264-\u2267\u226a-\u226b\u226e-\u226f\u2282-\u2283\u2286-\u2287\u2295\u2299\u22a5\u22bf\u2312\u2460-\u24e9\u24eb-\u254b\u2550-\u2573\u2580-\u258f\u2592-\u2595\u25a0-\u25a1\u25a3-\u25a9\u25b2-\u25b3\u25b6-\u25b7\u25bc-\u25bd\u25c0-\u25c1\u25c6-\u25c8\u25cb\u25ce-\u25d1\u25e2-\u25e5\u25ef\u2605-\u2606\u2609\u260e-\u260f\u261c\u261e\u2640\u2642\u2660-\u2661\u2663-\u2665\u2667-\u266a\u266c-\u266d\u266f\u269e-\u269f\u26bf\u26c6-\u26cd\u26cf-\u26d3\u26d5-\u26e1\u26e3\u26e8-\u26e9\u26eb-\u26f1\u26f4\u26f6-\u26f9\u26fb-\u26fc\u26fe-\u26ff\u273d\u2776-\u277f\u2b56-\u2b59\u3248-\u324f\ue000-\uf8ff\ufffd\u{1f100}-\u{1f10a}\u{1f110}-\u{1f12d}\u{1f130}-\u{1f169}\u{1f170}-\u{1f18d}\u{1f18f}-\u{1f190}\u{1f19b}-\u{1f1ac}\u{f0000}-\u{ffffd}\u{100000}-\u{10fffd}]/u;
43421
+ /**
42591
43422
  * U+FE0F VARIATION SELECTOR-16, which takes no width of its own but asks the
42592
43423
  * character before it to be drawn as an emoji — that is, in two columns rather
42593
43424
  * than one. Counting it as a column of its own is how that promotion is paid
@@ -42595,7 +43426,12 @@ const WIDE_CHARACTERS_PATTERN = /\p{Emoji_Presentation}|\p{Emoji_Modifier_Base}|
42595
43426
  * draws rather than the one the heart alone would.
42596
43427
  */
42597
43428
  const EMOJI_PRESENTATION_SELECTOR = "️";
42598
- /** Combining marks are drawn on top of the character before them, not beside it. */
43429
+ /**
43430
+ * Combining marks are drawn on top of the character before them, not beside it.
43431
+ *
43432
+ * Exported for the drift test on the ambiguous table, which has to leave out
43433
+ * exactly the characters this rule catches first.
43434
+ */
42599
43435
  const COMBINING_MARK_PATTERN = /[\p{Mn}\p{Me}]/u;
42600
43436
  /** The characters that take no width at all, marks aside. */
42601
43437
  const ZERO_WIDTH_CHARACTERS_PATTERN = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
@@ -42641,16 +43477,28 @@ function isCombiningMark(character) {
42641
43477
  return character !== EMOJI_PRESENTATION_SELECTOR && COMBINING_MARK_PATTERN.test(character);
42642
43478
  }
42643
43479
  /**
43480
+ * A lone surrogate, which is no character at all: a string can carry one — a
43481
+ * `"\ud800"` escape in JSON is enough — and the encoder that writes stdout
43482
+ * replaces it with U+FFFD on the way out, so the replacement character is what
43483
+ * the terminal draws and what has to be measured. U+FFFD is East Asian
43484
+ * Ambiguous, so the difference is a column apiece: 72 of them measured as
43485
+ * themselves fit a 72-column budget and are drawn in 144.
43486
+ */
43487
+ const LONE_SURROGATE_PATTERN = /\p{Cs}/u;
43488
+ /** What the stdout encoder writes in place of a lone surrogate. */
43489
+ const REPLACEMENT_CHARACTER = "�";
43490
+ /**
42644
43491
  * The width of one character, given how many marks already sit on the character
42645
43492
  * before it.
42646
43493
  */
42647
43494
  function widthInContext(params) {
42648
- const { character, precedingMarks } = params;
43495
+ const { precedingMarks } = params;
43496
+ const character = LONE_SURROGATE_PATTERN.test(params.character) ? REPLACEMENT_CHARACTER : params.character;
42649
43497
  if (character === EMOJI_PRESENTATION_SELECTOR) return 1;
42650
43498
  if (isCombiningMark(character)) return precedingMarks < FREE_MARKS_PER_CHARACTER ? 0 : 1;
42651
43499
  if (RENDERER_COUNTED_JOINERS.test(character)) return 1;
42652
43500
  if (ZERO_WIDTH_CHARACTERS_PATTERN.test(character)) return 0;
42653
- return WIDE_CHARACTERS_PATTERN.test(character) ? 2 : 1;
43501
+ return WIDE_CHARACTERS_PATTERN.test(character) || AMBIGUOUS_CHARACTERS_PATTERN.test(character) ? 2 : 1;
42654
43502
  }
42655
43503
  /**
42656
43504
  * How many terminal columns a string occupies.
@@ -42674,6 +43522,20 @@ function displayWidthOf(text) {
42674
43522
  /** The mark a cut string ends in. */
42675
43523
  const SHORTENING_ELLIPSIS = "…";
42676
43524
  /**
43525
+ * The columns a cut string is drawn in at the very least: what is left when
43526
+ * the budget has room for the mark of the cut and nothing before it. Two, since
43527
+ * the ellipsis is itself East Asian Ambiguous and is measured the way every
43528
+ * other such character is, so that a shortened label is not wider than it was
43529
+ * measured to be on the terminal the measurement is for.
43530
+ *
43531
+ * Exported because a caller composing several shortened pieces into one line
43532
+ * has to leave room for it to decide which of them gives way, and the width of
43533
+ * the mark is this module's business rather than something to be counted again
43534
+ * at the other end. Leaving room for it is not what bounds the line: cutting
43535
+ * the composed line is.
43536
+ */
43537
+ const ELLIPSIS_WIDTH = displayWidthOf(SHORTENING_ELLIPSIS);
43538
+ /**
42677
43539
  * Cut `text` down to at most `budget` columns, marking the cut with an ellipsis.
42678
43540
  *
42679
43541
  * The ellipsis is paid for out of the budget rather than added on top of it, so
@@ -42684,7 +43546,7 @@ const SHORTENING_ELLIPSIS = "…";
42684
43546
  function shortenToWidth(params) {
42685
43547
  const { text, budget } = params;
42686
43548
  if (displayWidthOf(text) <= budget) return text;
42687
- const target = budget - 1;
43549
+ const target = budget - ELLIPSIS_WIDTH;
42688
43550
  let width = 0;
42689
43551
  let marks = 0;
42690
43552
  const kept = [];
@@ -45622,7 +46484,7 @@ var AgentsmdSkill = class AgentsmdSkill extends SimulatedSkill {
45622
46484
  const relativeDirPath = this.getSettablePaths().relativeDirPath;
45623
46485
  const frontmatter = {
45624
46486
  ...defaults.frontmatter,
45625
- ...toSpecConformantAgentSkillFields(params.rulesyncSkill.getFrontmatter().agentsskills)
46487
+ ...toSpecConformantAgentSkillFields(resolveAgentsSkillsSection(params.rulesyncSkill.getFrontmatter()))
45626
46488
  };
45627
46489
  AgentsSkillsSkill.reportSpecViolations({
45628
46490
  outputRoot: params.outputRoot ?? process.cwd(),
@@ -45748,13 +46610,25 @@ var RovodevSkill = class RovodevSkill extends ToolSkill {
45748
46610
  const settablePaths = RovodevSkill.getSettablePaths({ global });
45749
46611
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
45750
46612
  const rovodevSection = rulesyncFrontmatter.rovodev;
46613
+ const license = resolveLicense({
46614
+ rootFrontmatter: rulesyncFrontmatter,
46615
+ section: rovodevSection
46616
+ });
46617
+ const compatibility = resolveCompatibility({
46618
+ rootFrontmatter: rulesyncFrontmatter,
46619
+ section: rovodevSection
46620
+ });
46621
+ const metadata = resolveMetadata({
46622
+ rootFrontmatter: rulesyncFrontmatter,
46623
+ section: rovodevSection
46624
+ });
45751
46625
  const rovodevFrontmatter = {
45752
46626
  name: rulesyncFrontmatter.name,
45753
46627
  description: rulesyncFrontmatter.description,
45754
46628
  ...rovodevSection?.["allowed-tools"] !== void 0 && { "allowed-tools": rovodevSection["allowed-tools"] },
45755
- ...rovodevSection?.license !== void 0 && { license: rovodevSection.license },
45756
- ...rovodevSection?.compatibility !== void 0 && { compatibility: rovodevSection.compatibility },
45757
- ...rovodevSection?.metadata !== void 0 && { metadata: rovodevSection.metadata }
46629
+ ...license !== void 0 && { license },
46630
+ ...compatibility !== void 0 && { compatibility },
46631
+ ...metadata !== void 0 && { metadata }
45758
46632
  };
45759
46633
  return new RovodevSkill({
45760
46634
  outputRoot,
@@ -45936,7 +46810,12 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
45936
46810
  composed: file.composed
45937
46811
  })) dirHasChanges = true;
45938
46812
  }
45939
- if (!dirHasChanges) continue;
46813
+ if (!dirHasChanges) {
46814
+ if (!this.dryRun) {
46815
+ for (const file of otherFiles) if (file.fileMode !== void 0) await restoreMissingExecutableBit(join(dirPath, file.relativeFilePathToDirPath), file.fileMode);
46816
+ }
46817
+ continue;
46818
+ }
45940
46819
  const relativeDir = aiDir.getRelativePathFromCwd();
45941
46820
  if (this.dryRun) {
45942
46821
  this.logger.info(`[DRY RUN] Would create directory: ${stripControlCharacters(dirPath)}`);
@@ -45955,7 +46834,9 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
45955
46834
  changedPaths.push(join(relativeDir, mainFile.name));
45956
46835
  }
45957
46836
  for (const file of otherFiles) {
45958
- await writeFileBuffer(join(dirPath, file.relativeFilePathToDirPath), file.fileBuffer);
46837
+ const filePath = join(dirPath, file.relativeFilePathToDirPath);
46838
+ await writeFileBuffer(filePath, file.fileBuffer);
46839
+ if (file.fileMode !== void 0) await applyFileMode(filePath, file.fileMode);
45959
46840
  changedPaths.push(join(relativeDir, file.relativeFilePathToDirPath));
45960
46841
  }
45961
46842
  }
@@ -45973,7 +46854,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
45973
46854
  async removeOrphanAiDirs(existingDirs, generatedDirs) {
45974
46855
  const generatedPaths = new Set(generatedDirs.map((d) => d.getDirPath()));
45975
46856
  const orphanPaths = /* @__PURE__ */ new Set();
45976
- const quotedOutputRoot = JSON.stringify(stripControlCharacters(this.outputRoot));
46857
+ const quotedOutputRoot = quoteForLog(this.outputRoot);
45977
46858
  for (const aiDir of existingDirs) {
45978
46859
  const dirPath = aiDir.getDirPath();
45979
46860
  const { verdict, root } = locateInOwnRoot({
@@ -45981,14 +46862,14 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
45981
46862
  dirPath,
45982
46863
  outputRoot: this.outputRoot
45983
46864
  });
45984
- const quotedDirPath = JSON.stringify(stripControlCharacters(dirPath));
45985
- const quotedRoot = JSON.stringify(stripControlCharacters(root));
46865
+ const quotedDirPath = quoteForLog(dirPath);
46866
+ const quotedRoot = quoteForLog(root);
45986
46867
  if (verdict === "root-outside") {
45987
46868
  this.logger.warn(`Refusing to delete ${quotedDirPath}: the root ${quotedRoot} it was found in is not inside ${quotedOutputRoot}, the directory this run writes to`);
45988
46869
  continue;
45989
46870
  }
45990
46871
  if (!aiDir.ownsDirTree()) {
45991
- if (verdict === "equal") this.logger.debug(`Skipping orphan sweep for ${JSON.stringify(stripControlCharacters(aiDir.getDirName()))}: ${quotedDirPath} is a shared root, not a directory of its own`);
46872
+ if (verdict === "equal") this.logger.debug(`Skipping orphan sweep for ${quoteForLog(aiDir.getDirName())}: ${quotedDirPath} is a shared root, not a directory of its own`);
45992
46873
  else this.logger.warn(`Refusing to delete ${quotedDirPath}: it does not own that directory, and it is not the shared root ${quotedRoot} it was found in either`);
45993
46874
  continue;
45994
46875
  }
@@ -46004,6 +46885,126 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46004
46885
  });
46005
46886
  }
46006
46887
  /**
46888
+ * Remove the files left inside a directory this run still generates, but
46889
+ * which the run no longer writes.
46890
+ *
46891
+ * The directory sweep above cannot see them: it removes a directory that no
46892
+ * longer corresponds to any generated entry, and never looks inside one that
46893
+ * does. So deleting a companion file from a source directory that is
46894
+ * otherwise kept left the generated copy in place — and, because change
46895
+ * detection compares only the files the run will write, the run reported
46896
+ * itself up to date while an agent went on reading the stale file. The same
46897
+ * gap left any file that was never rulesync's sitting inside a directory the
46898
+ * user now believes rulesync owns.
46899
+ *
46900
+ * Only a directory that owns its whole tree is swept, and only when this run
46901
+ * generated it. That is the same claim the directory sweep already acts on,
46902
+ * one level down: a directory whose entry disappears is deleted outright,
46903
+ * companion files and all, so a file inside one whose entry is still here and
46904
+ * which no source produces is stale by exactly the same reasoning.
46905
+ *
46906
+ * Two kinds of file are left alone:
46907
+ *
46908
+ * - **Hidden entries.** The loader does carry a hidden companion — a
46909
+ * `.env.example` beside the script that reads it is skill content — so a
46910
+ * hidden file here may be rulesync's. But a hidden name is also where a
46911
+ * user's own files live: a `.gitkeep`, a `.env` with real values in it. The
46912
+ * sweep cannot tell the two apart by name, and deleting the user's is the
46913
+ * worse mistake, so a stale hidden companion is the one leftover it
46914
+ * knowingly keeps.
46915
+ * - **Symbolic links**, which the writer never creates. The walk neither
46916
+ * follows nor reports them, so a link is never removed and never resolved
46917
+ * into a deletion somewhere outside the tree.
46918
+ *
46919
+ * Nothing is swept at all by a run that could not read its sources in full.
46920
+ * `AiDir` drops a companion file it cannot open, and stops short of a subtree
46921
+ * it is denied or that runs past one of its bounds -- warning each time, but
46922
+ * carrying on, because a skill that is short one file is still worth writing.
46923
+ * The output copy of such a file is then indistinguishable here from a file
46924
+ * whose source was deleted, and the wrong guess deletes something the next
46925
+ * readable run would put straight back. So a shortfall anywhere in the run
46926
+ * calls the whole sweep off: it is the sweeps that are optional, not the
46927
+ * files.
46928
+ *
46929
+ * @param isClaimed - Whether some other target or feature in this run wrote
46930
+ * this exact path. A shared output root -- `.agents/skills/`, written by
46931
+ * several targets at once -- is a directory whose entry here lists only
46932
+ * *this* target's files, so without the run's own record a sibling's fresh
46933
+ * output reads as an orphan. Asked per path rather than per tree: a tree
46934
+ * claim covers the directory this sweep is looking inside of, and would
46935
+ * answer yes to every file in it.
46936
+ */
46937
+ async removeOrphanFilesInAiDirs({ generatedDirs, isClaimed }) {
46938
+ if (hasIncompleteCarriedFiles()) {
46939
+ warnOnceWithFallback(this.logger, "Not sweeping the files inside generated directories: this run could not read every file its sources carry, so a file it did not write may still be one it wants. The warnings above name what it could not read.");
46940
+ return 0;
46941
+ }
46942
+ const orphanPaths = /* @__PURE__ */ new Set();
46943
+ const quotedOutputRoot = quoteForLog(this.outputRoot);
46944
+ for (const aiDir of generatedDirs) {
46945
+ const dirPath = aiDir.getDirPath();
46946
+ const { verdict, root } = locateInOwnRoot({
46947
+ aiDir,
46948
+ dirPath,
46949
+ outputRoot: this.outputRoot
46950
+ });
46951
+ const quotedDirPath = quoteForLog(dirPath);
46952
+ const quotedRoot = quoteForLog(root);
46953
+ if (verdict === "root-outside") {
46954
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: the root ${quotedRoot} it was found in is not inside ${quotedOutputRoot}, the directory this run writes to`);
46955
+ continue;
46956
+ }
46957
+ if (!aiDir.ownsDirTree()) {
46958
+ if (verdict === "equal") this.logger.debug(`Skipping orphan sweep for ${quoteForLog(aiDir.getDirName())}: ${quotedDirPath} is a shared root, not a directory of its own`);
46959
+ else this.logger.warn(`Refusing to sweep ${quotedDirPath}: it does not own that directory, and it is not the shared root ${quotedRoot} it was found in either`);
46960
+ continue;
46961
+ }
46962
+ if (verdict !== "inside") {
46963
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: it is not a directory inside ${quotedRoot}, the root it was found in`);
46964
+ continue;
46965
+ }
46966
+ try {
46967
+ await assertWritablePathInsideRoot({
46968
+ rootPath: this.outputRoot,
46969
+ targetPath: dirPath
46970
+ });
46971
+ } catch (error) {
46972
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: ${stripControlCharacters(formatError(error))}`);
46973
+ continue;
46974
+ }
46975
+ const generatedNames = /* @__PURE__ */ new Set();
46976
+ const mainFile = aiDir.getMainFile();
46977
+ if (mainFile) generatedNames.add(toPosixPath(mainFile.name));
46978
+ for (const file of aiDir.getOtherFiles()) generatedNames.add(toPosixPath(file.relativeFilePathToDirPath));
46979
+ const generatedNamesFolded = new Set([...generatedNames].map((name) => name.toLowerCase()));
46980
+ let existingNames;
46981
+ try {
46982
+ existingNames = await listFilePathsRecursively(dirPath, {
46983
+ followSymbolicLinks: false,
46984
+ includeHidden: false
46985
+ });
46986
+ } catch (error) {
46987
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: ${stripControlCharacters(formatError(error))}`);
46988
+ continue;
46989
+ }
46990
+ for (const existingName of existingNames) {
46991
+ const posixName = toPosixPath(existingName);
46992
+ if (generatedNames.has(posixName)) continue;
46993
+ const filePath = join(dirPath, existingName);
46994
+ if (generatedNamesFolded.has(posixName.toLowerCase())) {
46995
+ this.logger.warn(`Refusing to delete ${quoteForLog(filePath)}: this run wrote a file whose path differs from it only in case, which on a case-insensitive filesystem is the very file it wrote`);
46996
+ continue;
46997
+ }
46998
+ if (isClaimed(filePath)) continue;
46999
+ orphanPaths.add(filePath);
47000
+ }
47001
+ }
47002
+ return await this.deleteOrphanPaths({
47003
+ paths: orphanPaths,
47004
+ kind: "file"
47005
+ });
47006
+ }
47007
+ /**
46007
47008
  * Delete the paths the sweeps decided on, or report what a real run would
46008
47009
  * have deleted. Shared by both halves so the dry-run wording, the quoting of
46009
47010
  * a name that came off disk, and the count they return stay one behavior
@@ -46011,7 +47012,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46011
47012
  */
46012
47013
  async deleteOrphanPaths({ paths, kind }) {
46013
47014
  for (const targetPath of paths) {
46014
- const loggedPath = JSON.stringify(stripControlCharacters(targetPath));
47015
+ const loggedPath = quoteForLog(targetPath);
46015
47016
  if (this.dryRun) this.logger.info(`[DRY RUN] Would delete ${kind}: ${loggedPath}`);
46016
47017
  else {
46017
47018
  await (kind === "directory" ? removeDirectory(targetPath) : removeFile(targetPath));
@@ -46048,11 +47049,11 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46048
47049
  }
46049
47050
  const generatedPathsFolded = new Set([...generatedPaths].map((generatedPath) => generatedPath.toLowerCase()));
46050
47051
  const orphanPaths = /* @__PURE__ */ new Set();
46051
- const quotedOutputRoot = JSON.stringify(stripControlCharacters(this.outputRoot));
47052
+ const quotedOutputRoot = quoteForLog(this.outputRoot);
46052
47053
  for (const aiDir of existingFlatFiles) {
46053
47054
  const filePath = aiDir.getFlatFilePath();
46054
47055
  if (filePath === void 0) {
46055
- this.logger.warn(`Refusing to sweep ${JSON.stringify(stripControlCharacters(aiDir.getDirName()))}: it owns a directory of its own, or names no file directly under the root it was found in`);
47056
+ this.logger.warn(`Refusing to sweep ${quoteForLog(aiDir.getDirName())}: it owns a directory of its own, or names no file directly under the root it was found in`);
46056
47057
  continue;
46057
47058
  }
46058
47059
  const dirPath = aiDir.getDirPath();
@@ -46061,8 +47062,8 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46061
47062
  dirPath,
46062
47063
  outputRoot: this.outputRoot
46063
47064
  });
46064
- const quotedFilePath = JSON.stringify(stripControlCharacters(filePath));
46065
- const quotedRoot = JSON.stringify(stripControlCharacters(root));
47065
+ const quotedFilePath = quoteForLog(filePath);
47066
+ const quotedRoot = quoteForLog(root);
46066
47067
  if (verdict === "root-outside") {
46067
47068
  this.logger.warn(`Refusing to delete ${quotedFilePath}: the root ${quotedRoot} it was found in is not inside ${quotedOutputRoot}, the directory this run writes to`);
46068
47069
  continue;
@@ -46804,9 +47805,9 @@ async function checkNestedSkillsRoot({ outputRoot, dirPath }) {
46804
47805
  if (posixRelativePathEscapesRoot(realRelativeDirPath)) return { reason: "it resolves outside the project." };
46805
47806
  const segments = realRelativeDirPath.split("/");
46806
47807
  const aboveTailSegments = segments.slice(0, -CLAUDECODE_SKILLS_DIR_SEGMENTS.length);
46807
- if (segments.slice(-CLAUDECODE_SKILLS_DIR_SEGMENTS.length).join("/") !== CLAUDECODE_SKILLS_DIR_POSIX_PATH) return { reason: `it resolves to ${realRelativeDirPath === "" ? "the project root" : JSON.stringify(stripControlCharacters(realRelativeDirPath))}, which is not a ${CLAUDECODE_SKILLS_DIR_POSIX_PATH} directory.` };
47808
+ if (segments.slice(-CLAUDECODE_SKILLS_DIR_SEGMENTS.length).join("/") !== CLAUDECODE_SKILLS_DIR_POSIX_PATH) return { reason: `it resolves to ${realRelativeDirPath === "" ? "the project root" : quoteForLog(realRelativeDirPath)}, which is not a ${CLAUDECODE_SKILLS_DIR_POSIX_PATH} directory.` };
46808
47809
  const excludedSegment = excludedNestedScanSegment(aboveTailSegments);
46809
- if (excludedSegment !== void 0) return { reason: `it resolves inside ${JSON.stringify(stripControlCharacters(excludedSegment))}, which the nested scan excludes.` };
47810
+ if (excludedSegment !== void 0) return { reason: `it resolves inside ${quoteForLog(excludedSegment)}, which the nested scan excludes.` };
46810
47811
  return { realRelativeDirPath };
46811
47812
  }
46812
47813
  const ClaudecodeSkillFrontmatterSchema = z.looseObject({
@@ -46857,9 +47858,18 @@ function buildClaudecodeSkillFrontmatter({ rulesyncFrontmatter, resolvedDisableM
46857
47858
  "disable-model-invocation": resolvedDisableModelInvocation,
46858
47859
  "user-invocable": resolvedUserInvocable,
46859
47860
  paths: section.paths,
46860
- license: section.license,
46861
- compatibility: section.compatibility,
46862
- metadata: section.metadata
47861
+ license: resolveLicense({
47862
+ rootFrontmatter: rulesyncFrontmatter,
47863
+ section
47864
+ }),
47865
+ compatibility: resolveCompatibility({
47866
+ rootFrontmatter: rulesyncFrontmatter,
47867
+ section
47868
+ }),
47869
+ metadata: resolveMetadata({
47870
+ rootFrontmatter: rulesyncFrontmatter,
47871
+ section
47872
+ })
46863
47873
  };
46864
47874
  const frontmatter = {
46865
47875
  name: rulesyncFrontmatter.name,
@@ -47059,21 +48069,25 @@ var ClaudecodeSkill = class extends ToolSkill {
47059
48069
  */
47060
48070
  static async getConfiguredImportRoots({ outputRoot, global = false, logger }) {
47061
48071
  if (global) return [];
47062
- const root = toPosixPath(outputRoot);
48072
+ const skillsDirPath = toPosixPath(CLAUDECODE_SKILLS_DIR_PATH);
47063
48073
  const filteredDirPaths = filterOutPathsInGitIgnoredDirectories({
47064
48074
  rootDir: outputRoot,
47065
- filePaths: await findFilesByGlobs([`${root}/*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`], {
48075
+ filePaths: await findFilesByGlobs([`*/**/${skillsDirPath}`], {
48076
+ cwd: outputRoot,
47066
48077
  type: "dir",
47067
48078
  followSymbolicLinks: false,
47068
48079
  ignore: [
47069
- `${root}/**/.*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`,
47070
- ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `${root}/**/${dir}/**`),
47071
- ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
48080
+ `**/.*/**/${skillsDirPath}`,
48081
+ ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `**/${dir}/**`),
48082
+ ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${dir}/**`)
47072
48083
  ]
47073
48084
  })
47074
48085
  }).toSorted();
47075
48086
  const roots = [];
47076
- const seenRealRelativeDirPaths = /* @__PURE__ */ new Set([CLAUDECODE_SKILLS_DIR_POSIX_PATH]);
48087
+ const seenRealRelativeDirPaths = /* @__PURE__ */ new Set([CLAUDECODE_SKILLS_DIR_POSIX_PATH, await resolvedRelativePath({
48088
+ rootPath: outputRoot,
48089
+ targetPath: join(outputRoot, CLAUDECODE_SKILLS_DIR_PATH)
48090
+ })]);
47077
48091
  for (const dirPath of filteredDirPaths) {
47078
48092
  const scannedDirPath = resolve(dirPath);
47079
48093
  const check = await checkNestedSkillsRoot({
@@ -47081,7 +48095,7 @@ var ClaudecodeSkill = class extends ToolSkill {
47081
48095
  dirPath: scannedDirPath
47082
48096
  });
47083
48097
  if ("reason" in check) {
47084
- logger?.warn(`Skipping the nested Claude Code skills directory ${JSON.stringify(stripControlCharacters(scannedDirPath))}: ${check.reason} Its skills are not imported.`);
48098
+ logger?.warn(`Skipping the nested Claude Code skills directory ${quoteForLog(scannedDirPath)}: ${check.reason} Its skills are not imported.`);
47085
48099
  continue;
47086
48100
  }
47087
48101
  if (seenRealRelativeDirPaths.has(check.realRelativeDirPath)) continue;
@@ -47668,11 +48682,16 @@ var CopilotSkill = class CopilotSkill extends ToolSkill {
47668
48682
  rootFrontmatter: rulesyncFrontmatter,
47669
48683
  section: copilotSection
47670
48684
  });
47671
- const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, ...copilotFields } = copilotSection ?? {};
48685
+ const license = resolveLicense({
48686
+ rootFrontmatter: rulesyncFrontmatter,
48687
+ section: copilotSection
48688
+ });
48689
+ const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, license: _license, ...copilotFields } = copilotSection ?? {};
47672
48690
  const copilotFrontmatter = {
47673
48691
  ...copilotFields,
47674
48692
  name: rulesyncFrontmatter.name,
47675
48693
  description: rulesyncFrontmatter.description,
48694
+ ...license !== void 0 && { license },
47676
48695
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
47677
48696
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
47678
48697
  };
@@ -47823,11 +48842,16 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
47823
48842
  rootFrontmatter: rulesyncFrontmatter,
47824
48843
  section: copilotcliSection
47825
48844
  });
47826
- const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, ...copilotcliFields } = copilotcliSection ?? {};
48845
+ const license = resolveLicense({
48846
+ rootFrontmatter: rulesyncFrontmatter,
48847
+ section: copilotcliSection
48848
+ });
48849
+ const { "user-invocable": _userInvocable, "disable-model-invocation": _disableModelInvocation, license: _license, ...copilotcliFields } = copilotcliSection ?? {};
47827
48850
  const copilotcliFrontmatter = {
47828
48851
  ...copilotcliFields,
47829
48852
  name: rulesyncFrontmatter.name,
47830
48853
  description: rulesyncFrontmatter.description,
48854
+ ...license !== void 0 && { license },
47831
48855
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
47832
48856
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
47833
48857
  };
@@ -47978,13 +49002,17 @@ var CursorSkill = class CursorSkill extends ToolSkill {
47978
49002
  rootFrontmatter: rulesyncFrontmatter,
47979
49003
  section: cursorSection
47980
49004
  });
49005
+ const metadata = resolveMetadata({
49006
+ rootFrontmatter: rulesyncFrontmatter,
49007
+ section: cursorSection
49008
+ });
47981
49009
  const cursorFrontmatter = {
47982
49010
  name: rulesyncFrontmatter.name,
47983
49011
  description: rulesyncFrontmatter.description,
47984
49012
  ...cursorSection?.paths !== void 0 && { paths: cursorSection.paths },
47985
49013
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
47986
49014
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
47987
- ...cursorSection?.metadata !== void 0 && { metadata: cursorSection.metadata }
49015
+ ...metadata !== void 0 && { metadata }
47988
49016
  };
47989
49017
  return new CursorSkill({
47990
49018
  outputRoot,
@@ -48125,13 +49153,25 @@ var DeepagentsSkill = class DeepagentsSkill extends ToolSkill {
48125
49153
  const deepagentsSection = rulesyncFrontmatter.deepagents;
48126
49154
  const allowedTools = deepagentsSection?.["allowed-tools"];
48127
49155
  const allowedToolsString = Array.isArray(allowedTools) ? allowedTools.join(" ") : allowedTools;
49156
+ const license = resolveLicense({
49157
+ rootFrontmatter: rulesyncFrontmatter,
49158
+ section: deepagentsSection
49159
+ });
49160
+ const compatibility = resolveCompatibility({
49161
+ rootFrontmatter: rulesyncFrontmatter,
49162
+ section: deepagentsSection
49163
+ });
49164
+ const metadata = resolveMetadata({
49165
+ rootFrontmatter: rulesyncFrontmatter,
49166
+ section: deepagentsSection
49167
+ });
48128
49168
  const deepagentsFrontmatter = {
48129
49169
  name: rulesyncFrontmatter.name,
48130
49170
  description: rulesyncFrontmatter.description,
48131
49171
  ...allowedToolsString && { "allowed-tools": allowedToolsString },
48132
- ...deepagentsSection?.license !== void 0 && { license: deepagentsSection.license },
48133
- ...deepagentsSection?.compatibility !== void 0 && { compatibility: deepagentsSection.compatibility },
48134
- ...deepagentsSection?.metadata !== void 0 && { metadata: deepagentsSection.metadata }
49172
+ ...license !== void 0 && { license },
49173
+ ...compatibility !== void 0 && { compatibility },
49174
+ ...metadata !== void 0 && { metadata }
48135
49175
  };
48136
49176
  return new DeepagentsSkill({
48137
49177
  outputRoot,
@@ -48483,11 +49523,26 @@ var FactorydroidSkill = class FactorydroidSkill extends ToolSkill {
48483
49523
  rootFrontmatter: rulesyncFrontmatter,
48484
49524
  section: factorydroidSection
48485
49525
  });
49526
+ const license = resolveLicense({
49527
+ rootFrontmatter: rulesyncFrontmatter,
49528
+ section: factorydroidSection
49529
+ });
49530
+ const compatibility = resolveCompatibility({
49531
+ rootFrontmatter: rulesyncFrontmatter,
49532
+ section: factorydroidSection
49533
+ });
49534
+ const metadata = resolveMetadata({
49535
+ rootFrontmatter: rulesyncFrontmatter,
49536
+ section: factorydroidSection
49537
+ });
48486
49538
  const { name: _sectionName, description: _sectionDescription, ...section } = factorydroidSection ?? {};
48487
49539
  const factorydroidFrontmatter = {
48488
49540
  name: rulesyncFrontmatter.name,
48489
49541
  description: rulesyncFrontmatter.description,
48490
49542
  ...section,
49543
+ ...license !== void 0 && { license },
49544
+ ...compatibility !== void 0 && { compatibility },
49545
+ ...metadata !== void 0 && { metadata },
48491
49546
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
48492
49547
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable }
48493
49548
  };
@@ -49079,7 +50134,7 @@ const KiloSkillFrontmatterSchema = z.looseObject({
49079
50134
  name: z.string(),
49080
50135
  description: z.string(),
49081
50136
  license: z.optional(z.string()),
49082
- compatibility: z.optional(z.looseObject({})),
50137
+ compatibility: z.optional(z.union([z.string(), z.looseObject({})])),
49083
50138
  metadata: z.optional(z.looseObject({})),
49084
50139
  "allowed-tools": z.optional(z.array(z.string()))
49085
50140
  });
@@ -49154,13 +50209,25 @@ var KiloSkill = class KiloSkill extends ToolSkill {
49154
50209
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
49155
50210
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
49156
50211
  const kiloSection = rulesyncFrontmatter.kilo;
50212
+ const license = resolveLicense({
50213
+ rootFrontmatter: rulesyncFrontmatter,
50214
+ section: kiloSection
50215
+ });
50216
+ const compatibility = resolveCompatibility({
50217
+ rootFrontmatter: rulesyncFrontmatter,
50218
+ section: kiloSection
50219
+ });
50220
+ const metadata = resolveMetadata({
50221
+ rootFrontmatter: rulesyncFrontmatter,
50222
+ section: kiloSection
50223
+ });
49157
50224
  const kiloFrontmatter = {
49158
50225
  name: rulesyncFrontmatter.name,
49159
50226
  description: rulesyncFrontmatter.description,
49160
50227
  ...kiloSection?.["allowed-tools"] !== void 0 && { "allowed-tools": kiloSection["allowed-tools"] },
49161
- ...kiloSection?.license !== void 0 && { license: kiloSection.license },
49162
- ...kiloSection?.compatibility !== void 0 && { compatibility: kiloSection.compatibility },
49163
- ...kiloSection?.metadata !== void 0 && { metadata: kiloSection.metadata }
50228
+ ...license !== void 0 && { license },
50229
+ ...compatibility !== void 0 && { compatibility },
50230
+ ...metadata !== void 0 && { metadata }
49164
50231
  };
49165
50232
  const settablePaths = KiloSkill.getSettablePaths({ global });
49166
50233
  return new KiloSkill({
@@ -49492,10 +50559,26 @@ var KiroSkill = class KiroSkill extends ToolSkill {
49492
50559
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
49493
50560
  const settablePaths = KiroSkill.getSettablePaths({ global });
49494
50561
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
50562
+ const kiroSection = rulesyncFrontmatter.kiro;
50563
+ const license = resolveLicense({
50564
+ rootFrontmatter: rulesyncFrontmatter,
50565
+ section: kiroSection
50566
+ });
50567
+ const compatibility = resolveCompatibility({
50568
+ rootFrontmatter: rulesyncFrontmatter,
50569
+ section: kiroSection
50570
+ });
50571
+ const metadata = resolveMetadata({
50572
+ rootFrontmatter: rulesyncFrontmatter,
50573
+ section: kiroSection
50574
+ });
49495
50575
  const kiroFrontmatter = {
49496
- ...rulesyncFrontmatter.kiro,
50576
+ ...kiroSection,
49497
50577
  name: rulesyncFrontmatter.name,
49498
- description: rulesyncFrontmatter.description
50578
+ description: rulesyncFrontmatter.description,
50579
+ ...license !== void 0 && { license },
50580
+ ...compatibility !== void 0 && { compatibility },
50581
+ ...metadata !== void 0 && { metadata }
49499
50582
  };
49500
50583
  return new KiroSkill({
49501
50584
  outputRoot,
@@ -49729,15 +50812,6 @@ const OpenCodeSkillFrontmatterSchema = z.looseObject({
49729
50812
  metadata: z.optional(z.looseObject({})),
49730
50813
  "allowed-tools": z.optional(z.array(z.string()))
49731
50814
  });
49732
- /**
49733
- * Reads a top-level `compatibility` value from rulesync frontmatter, accepting
49734
- * both the documented string form (e.g. `compatibility: opencode`) and the
49735
- * legacy object form. Returns `undefined` for any other shape.
49736
- */
49737
- function readTopLevelCompatibility(value) {
49738
- if (typeof value === "string") return value;
49739
- if (typeof value === "object" && value !== null) return value;
49740
- }
49741
50815
  var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
49742
50816
  constructor({ outputRoot = process.cwd(), relativeDirPath = OPENCODE_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
49743
50817
  super({
@@ -49773,9 +50847,16 @@ var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
49773
50847
  * configured path is read but never generated into. `skills.urls` is a
49774
50848
  * remote-fetch surface and is out of scope for a file-based generator.
49775
50849
  *
49776
- * Absolute paths and paths escaping the output root are dropped — an import
49777
- * root is joined onto `outputRoot`, and reaching outside it is not something
49778
- * a project config should be able to ask for.
50850
+ * Absolute paths and paths escaping the config directory are dropped — an
50851
+ * import root is joined onto that directory, and reaching outside it is not
50852
+ * something a project config should be able to ask for. A path is judged by
50853
+ * where it resolves, not by how it is spelled: a relative name that is a
50854
+ * symbolic link pointing out of the directory is an escape all the same and
50855
+ * is dropped too. A path that does not exist has nothing to resolve and is
50856
+ * compared as spelled against the resolved config directory, so it survives
50857
+ * only when that directory resolves to its own spelling and is dropped when
50858
+ * the directory is itself reached through a link. Either way it yields no
50859
+ * skills — the scan finds no directory under it.
49779
50860
  *
49780
50861
  * @see https://opencode.ai/config.json
49781
50862
  */
@@ -49789,7 +50870,12 @@ var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
49789
50870
  outputRoot,
49790
50871
  global
49791
50872
  });
49792
- return skills.paths.filter((candidate) => typeof candidate === "string" && candidate !== "" && !isAbsolute(candidate) && !normalize(candidate).startsWith("..")).map((relativeDirPath) => ({
50873
+ const lexicallyContained = skills.paths.filter((candidate) => typeof candidate === "string" && candidate !== "" && !isAbsolute(candidate) && !normalize(candidate).startsWith(".."));
50874
+ const escapes = await Promise.all(lexicallyContained.map((candidate) => resolvedPathEscapesRoot({
50875
+ rootPath: configDir,
50876
+ targetPath: join(configDir, candidate)
50877
+ })));
50878
+ return lexicallyContained.filter((_, index) => !escapes[index]).map((relativeDirPath) => ({
49793
50879
  outputRoot: configDir,
49794
50880
  relativeDirPath
49795
50881
  }));
@@ -49843,13 +50929,18 @@ var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
49843
50929
  static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
49844
50930
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
49845
50931
  const opencodeSection = rulesyncFrontmatter.opencode;
49846
- const looseTopLevel = rulesyncFrontmatter;
49847
- const topLevelLicense = typeof looseTopLevel.license === "string" ? looseTopLevel.license : void 0;
49848
- const topLevelCompatibility = readTopLevelCompatibility(looseTopLevel.compatibility);
49849
- const topLevelMetadata = typeof looseTopLevel.metadata === "object" && looseTopLevel.metadata !== null ? looseTopLevel.metadata : void 0;
49850
- const license = opencodeSection?.license ?? topLevelLicense;
49851
- const compatibility = opencodeSection?.compatibility ?? topLevelCompatibility;
49852
- const metadata = opencodeSection?.metadata ?? topLevelMetadata;
50932
+ const license = resolveLicense({
50933
+ rootFrontmatter: rulesyncFrontmatter,
50934
+ section: opencodeSection
50935
+ });
50936
+ const compatibility = resolveCompatibility({
50937
+ rootFrontmatter: rulesyncFrontmatter,
50938
+ section: opencodeSection
50939
+ });
50940
+ const metadata = resolveMetadata({
50941
+ rootFrontmatter: rulesyncFrontmatter,
50942
+ section: opencodeSection
50943
+ });
49853
50944
  const opencodeFrontmatter = {
49854
50945
  name: rulesyncFrontmatter.name,
49855
50946
  description: rulesyncFrontmatter.description,
@@ -50018,11 +51109,26 @@ var PiSkill = class PiSkill extends ToolSkill {
50018
51109
  });
50019
51110
  const { "allowed-tools": allowedTools, ...piSectionRest } = piSection ?? {};
50020
51111
  const allowedToolsString = Array.isArray(allowedTools) ? allowedTools.join(" ") : allowedTools;
51112
+ const license = resolveLicense({
51113
+ rootFrontmatter: rulesyncFrontmatter,
51114
+ section: piSection
51115
+ });
51116
+ const compatibility = resolveCompatibility({
51117
+ rootFrontmatter: rulesyncFrontmatter,
51118
+ section: piSection
51119
+ });
51120
+ const metadata = resolveMetadata({
51121
+ rootFrontmatter: rulesyncFrontmatter,
51122
+ section: piSection
51123
+ });
50021
51124
  const piFrontmatter = {
50022
51125
  name: rulesyncFrontmatter.name,
50023
51126
  description: rulesyncFrontmatter.description,
50024
51127
  ...allowedToolsString && { "allowed-tools": allowedToolsString },
50025
51128
  ...piSectionRest,
51129
+ ...license !== void 0 && { license },
51130
+ ...compatibility !== void 0 && { compatibility },
51131
+ ...metadata !== void 0 && { metadata },
50026
51132
  ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation }
50027
51133
  };
50028
51134
  return new PiSkill({
@@ -50504,11 +51610,26 @@ var ReplitSkill = class ReplitSkill extends ToolSkill {
50504
51610
  const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
50505
51611
  const { "allowed-tools": allowedTools, ...replitSection } = rulesyncFrontmatter.replit ?? {};
50506
51612
  const allowedToolsString = Array.isArray(allowedTools) ? allowedTools.join(" ") : allowedTools;
51613
+ const license = resolveLicense({
51614
+ rootFrontmatter: rulesyncFrontmatter,
51615
+ section: replitSection
51616
+ });
51617
+ const compatibility = resolveCompatibility({
51618
+ rootFrontmatter: rulesyncFrontmatter,
51619
+ section: replitSection
51620
+ });
51621
+ const metadata = resolveMetadata({
51622
+ rootFrontmatter: rulesyncFrontmatter,
51623
+ section: replitSection
51624
+ });
50507
51625
  const replitFrontmatter = {
50508
51626
  name: rulesyncFrontmatter.name,
50509
51627
  description: rulesyncFrontmatter.description,
50510
51628
  ...allowedToolsString && { "allowed-tools": allowedToolsString },
50511
- ...replitSection
51629
+ ...replitSection,
51630
+ ...license !== void 0 && { license },
51631
+ ...compatibility !== void 0 && { compatibility },
51632
+ ...metadata !== void 0 && { metadata }
50512
51633
  };
50513
51634
  return new ReplitSkill({
50514
51635
  outputRoot,
@@ -50861,30 +51982,24 @@ const VibeSkillFrontmatterSchema = z.looseObject({
50861
51982
  "user-invocable": z.optional(z.boolean()),
50862
51983
  "allowed-tools": z.optional(z.union([z.string(), z.array(z.string())]))
50863
51984
  });
50864
- /** Resolve the top-level `license` field, if present as a string. */
50865
- function resolveTopLevelLicense(looseTopLevel) {
50866
- return typeof looseTopLevel.license === "string" ? looseTopLevel.license : void 0;
50867
- }
50868
- /** Resolve the top-level `compatibility` field (string or object), if present. */
50869
- function resolveTopLevelCompatibility(looseTopLevel) {
50870
- const value = looseTopLevel.compatibility;
50871
- if (typeof value === "string" || typeof value === "object" && value !== null) return value;
50872
- }
50873
- /** Resolve the top-level `metadata` field (object), if present. */
50874
- function resolveTopLevelMetadata(looseTopLevel) {
50875
- const value = looseTopLevel.metadata;
50876
- return typeof value === "object" && value !== null ? value : void 0;
50877
- }
50878
51985
  /**
50879
51986
  * Build the Vibe frontmatter from a rulesync skill frontmatter, preferring the
50880
- * dedicated `vibe` section over any loosely-typed top-level fields.
51987
+ * dedicated `vibe` section over the shared root-level fields.
50881
51988
  */
50882
51989
  function buildVibeFrontmatter(rulesyncFrontmatter) {
50883
51990
  const vibeSection = rulesyncFrontmatter.vibe;
50884
- const looseTopLevel = rulesyncFrontmatter;
50885
- const topLevelLicense = resolveTopLevelLicense(looseTopLevel);
50886
- const topLevelCompatibility = resolveTopLevelCompatibility(looseTopLevel);
50887
- const topLevelMetadata = resolveTopLevelMetadata(looseTopLevel);
51991
+ const license = resolveLicense({
51992
+ rootFrontmatter: rulesyncFrontmatter,
51993
+ section: vibeSection
51994
+ });
51995
+ const compatibility = resolveCompatibility({
51996
+ rootFrontmatter: rulesyncFrontmatter,
51997
+ section: vibeSection
51998
+ });
51999
+ const metadata = resolveMetadata({
52000
+ rootFrontmatter: rulesyncFrontmatter,
52001
+ section: vibeSection
52002
+ });
50888
52003
  const resolvedUserInvocable = resolveUserInvocable({
50889
52004
  rootFrontmatter: rulesyncFrontmatter,
50890
52005
  section: vibeSection
@@ -50892,9 +52007,9 @@ function buildVibeFrontmatter(rulesyncFrontmatter) {
50892
52007
  return {
50893
52008
  name: rulesyncFrontmatter.name,
50894
52009
  description: rulesyncFrontmatter.description,
50895
- ...vibeSection?.license !== void 0 || topLevelLicense !== void 0 ? { license: vibeSection?.license ?? topLevelLicense } : {},
50896
- ...vibeSection?.compatibility !== void 0 || topLevelCompatibility !== void 0 ? { compatibility: vibeSection?.compatibility ?? topLevelCompatibility } : {},
50897
- ...vibeSection?.metadata !== void 0 || topLevelMetadata !== void 0 ? { metadata: vibeSection?.metadata ?? topLevelMetadata } : {},
52010
+ ...license !== void 0 && { license },
52011
+ ...compatibility !== void 0 && { compatibility },
52012
+ ...metadata !== void 0 && { metadata },
50898
52013
  ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
50899
52014
  ...vibeSection?.["allowed-tools"] !== void 0 && { "allowed-tools": vibeSection["allowed-tools"] }
50900
52015
  };
@@ -51874,7 +52989,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
51874
52989
  global: this.global
51875
52990
  });
51876
52991
  if (dirWriteBlockReason !== void 0 && dirWriteBlockReason !== null) {
51877
- this.logger.warn(`Skipping skill ${JSON.stringify(stripControlCharacters(dirName))} for '${this.toolTarget}': ${dirWriteBlockReason}`);
52992
+ this.logger.warn(`Skipping skill ${quoteForLog(dirName)} for '${this.toolTarget}': ${dirWriteBlockReason}`);
51878
52993
  return null;
51879
52994
  }
51880
52995
  return factory.class.fromRulesyncSkill({
@@ -52030,7 +53145,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
52030
53145
  };
52031
53146
  } catch (error) {
52032
53147
  if (!isLenientRoot) throw error;
52033
- this.logger.warn(`Skipping ${JSON.stringify(stripControlCharacters(sourcePath))}: ` + stripControlCharacters(formatError(error)));
53148
+ this.logger.warn(`Skipping ${quoteForLog(sourcePath)}: ` + stripControlCharacters(formatError(error)));
52034
53149
  return null;
52035
53150
  }
52036
53151
  }))).filter((loaded) => loaded !== null);
@@ -52061,7 +53176,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
52061
53176
  };
52062
53177
  } catch (error) {
52063
53178
  if (!isLenientRoot) throw error;
52064
- this.logger.warn(`Skipping ${JSON.stringify(stripControlCharacters(sourcePath))}: ` + stripControlCharacters(formatError(error)));
53179
+ this.logger.warn(`Skipping ${quoteForLog(sourcePath)}: ` + stripControlCharacters(formatError(error)));
52065
53180
  return null;
52066
53181
  }
52067
53182
  }))).filter((loaded) => loaded !== null);
@@ -52089,7 +53204,7 @@ var SkillsProcessor = class extends DirFeatureProcessor {
52089
53204
  return names.filter((name) => {
52090
53205
  if (isAddressableSkillName(kind === "file" ? basename(name, ".md") : name)) return true;
52091
53206
  const consequence = kind === "file" ? "skill name cannot contain a path separator, so this file is neither imported nor swept as an orphan. Rename it by hand." : "skill directory name cannot contain a path separator, so this directory is neither generated from nor swept as an orphan. Rename or remove it by hand.";
52092
- warnOnceWithFallback(this.logger, `Skipping ${JSON.stringify(stripControlCharacters(join(dirPath, name)))}: a ${consequence}`);
53207
+ warnOnceWithFallback(this.logger, `Skipping ${quoteForLog(join(dirPath, name))}: a ${consequence}`);
52093
53208
  return false;
52094
53209
  });
52095
53210
  }
@@ -57025,7 +58140,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
57025
58140
  supportsProject: true,
57026
58141
  supportsSimulated: false,
57027
58142
  supportsGlobal: true,
57028
- filePattern: join("*", "AGENTS.md")
58143
+ filePattern: "*/AGENTS.md"
57029
58144
  }
57030
58145
  }],
57031
58146
  ["devin", {
@@ -57034,7 +58149,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
57034
58149
  supportsProject: true,
57035
58150
  supportsSimulated: false,
57036
58151
  supportsGlobal: true,
57037
- filePattern: join("*", "AGENT.md")
58152
+ filePattern: "*/AGENT.md"
57038
58153
  }
57039
58154
  }],
57040
58155
  ["factorydroid", {
@@ -57124,7 +58239,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
57124
58239
  supportsProject: true,
57125
58240
  supportsSimulated: false,
57126
58241
  supportsGlobal: true,
57127
- filePattern: join("**", "*.md")
58242
+ filePattern: "**/*.md"
57128
58243
  }
57129
58244
  }],
57130
58245
  ["opencode", {
@@ -57151,7 +58266,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
57151
58266
  supportsProject: true,
57152
58267
  supportsSimulated: false,
57153
58268
  supportsGlobal: true,
57154
- filePattern: join("*", "SKILL.md")
58269
+ filePattern: "*/SKILL.md"
57155
58270
  }
57156
58271
  }],
57157
58272
  ["roo", {
@@ -57388,7 +58503,10 @@ var SubagentsProcessor = class extends FeatureProcessor {
57388
58503
  rootPath: rootOutputRoot,
57389
58504
  targetPath: baseDir
57390
58505
  });
57391
- const subagentFilePaths = await findFilesByGlobs(join(baseDir, factory.meta.filePattern), { followSymbolicLinks: !forDeletion });
58506
+ const subagentFilePaths = await findFilesByGlobs(factory.meta.filePattern, {
58507
+ cwd: baseDir,
58508
+ followSymbolicLinks: !forDeletion
58509
+ });
57392
58510
  const toRelativeFilePath = (path) => relative(baseDir, path);
57393
58511
  let ownedFilePaths = subagentFilePaths;
57394
58512
  if (factory.class.isFileOwned) {
@@ -57611,15 +58729,14 @@ var ToolRule = class extends ToolFile {
57611
58729
  * than files under a rulesync-owned directory, so enumerating them for
57612
58730
  * `--delete` would sweep away work rulesync never wrote.
57613
58731
  */
57614
- static buildNestedFilePatterns({ outputRoot, fileName }) {
57615
- const root = toPosixPath(outputRoot);
58732
+ static buildNestedFilePatterns({ fileName }) {
57616
58733
  return {
57617
- include: [`${root}/**/${fileName}`],
58734
+ include: [`**/${fileName}`],
57618
58735
  ignore: [
57619
- `${root}/${fileName}`,
57620
- `${root}/**/.*/**`,
57621
- ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `${root}/**/${dir}/**`),
57622
- ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
58736
+ fileName,
58737
+ "**/.*/**",
58738
+ ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `**/${dir}/**`),
58739
+ ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${dir}/**`)
57623
58740
  ]
57624
58741
  };
57625
58742
  }
@@ -57780,11 +58897,8 @@ var AgentsMdRule = class AgentsMdRule extends ToolRule {
57780
58897
  *
57781
58898
  * @see https://agents.md/
57782
58899
  */
57783
- static getNestedFilePatterns({ outputRoot }) {
57784
- return this.buildNestedFilePatterns({
57785
- outputRoot,
57786
- fileName: AGENTSMD_RULE_FILE_NAME
57787
- });
58900
+ static getNestedFilePatterns() {
58901
+ return this.buildNestedFilePatterns({ fileName: AGENTSMD_RULE_FILE_NAME });
57788
58902
  }
57789
58903
  /**
57790
58904
  * The subproject directory this rule scopes, or `undefined` for the project
@@ -58733,6 +59847,78 @@ var AugmentcodeRule = class AugmentcodeRule extends ToolRule {
58733
59847
  }
58734
59848
  };
58735
59849
  //#endregion
59850
+ //#region src/features/rules/claudecode-language-settings.ts
59851
+ /**
59852
+ * Claude Code's native home for the root `language` key of `rulesync.jsonc`.
59853
+ *
59854
+ * Claude Code reads a top-level `language` setting, so for this target the
59855
+ * rules feature writes the preference there instead of appending a prompt
59856
+ * block to CLAUDE.md. The key is patched into the existing settings file
59857
+ * through the shared config gateway (the `rules` feature owns `language`
59858
+ * there and nothing else), so hooks, permissions, and deny lists written by
59859
+ * sibling features — and everything the user authored — are left alone.
59860
+ *
59861
+ * Removing `language` from `rulesync.jsonc` later does not retract the key
59862
+ * already written: the settings file is shared with the user's own
59863
+ * configuration, so nothing is ever deleted from it, and the value stays
59864
+ * until the user removes it by hand.
59865
+ */
59866
+ var ClaudecodeLanguageSettings = class ClaudecodeLanguageSettings extends ToolFile {
59867
+ /**
59868
+ * Project scope goes to `.claude/settings.local.json`: a response language
59869
+ * is a per-developer preference, and the local file is the one Claude Code
59870
+ * keeps out of version control. Global scope goes to `~/.claude/settings.json`
59871
+ * because Claude Code reads no `~/.claude/settings.local.json`.
59872
+ */
59873
+ static getSettablePaths({ global = false } = {}) {
59874
+ return {
59875
+ relativeDirPath: CLAUDECODE_DIR,
59876
+ relativeFilePath: global ? CLAUDECODE_SETTINGS_FILE_NAME : CLAUDECODE_SETTINGS_LOCAL_FILE_NAME
59877
+ };
59878
+ }
59879
+ static async fromLanguage({ outputRoot = process.cwd(), language, global = false, validate = true }) {
59880
+ const paths = this.getSettablePaths({ global });
59881
+ const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
59882
+ const existingContent = await readFileContentOrNull(filePath) ?? "";
59883
+ return new ClaudecodeLanguageSettings({
59884
+ outputRoot,
59885
+ relativeDirPath: paths.relativeDirPath,
59886
+ relativeFilePath: paths.relativeFilePath,
59887
+ fileContent: applySharedConfigPatch({
59888
+ fileKey: sharedConfigFileKey(paths),
59889
+ feature: "rules",
59890
+ existingContent,
59891
+ patch: { language: getClaudecodeLanguageValue(language) },
59892
+ filePath
59893
+ }),
59894
+ validate,
59895
+ global
59896
+ });
59897
+ }
59898
+ /**
59899
+ * A settings file the user (and other features) share: never swept as an
59900
+ * orphan, even though the rules feature stops writing it when `language`
59901
+ * is unset again.
59902
+ */
59903
+ isDeletable() {
59904
+ return false;
59905
+ }
59906
+ validate() {
59907
+ try {
59908
+ JSON.parse(this.fileContent);
59909
+ return {
59910
+ success: true,
59911
+ error: null
59912
+ };
59913
+ } catch (error) {
59914
+ return {
59915
+ success: false,
59916
+ error: new Error(`Invalid JSON in ${this.getRelativePathFromCwd()}: ${formatError(error)}`, { cause: error })
59917
+ };
59918
+ }
59919
+ }
59920
+ };
59921
+ //#endregion
58736
59922
  //#region src/features/rules/claudecode-legacy-rule.ts
58737
59923
  /**
58738
59924
  * Legacy rule generator for Claude Code AI assistant
@@ -58868,6 +60054,15 @@ var ClaudecodeRule = class ClaudecodeRule extends ToolRule {
58868
60054
  nonRoot: { relativeDirPath: buildToolPath(CLAUDECODE_DIR, CLAUDECODE_RULES_DIR_NAME, excludeToolDir) }
58869
60055
  };
58870
60056
  }
60057
+ /**
60058
+ * The settings file the rules feature patches `language` into (see
60059
+ * {@link ClaudecodeLanguageSettings}): not a rule path, so `getSettablePaths`
60060
+ * does not report it, but the shared-config gateway must know the rules
60061
+ * feature writes there.
60062
+ */
60063
+ static getExtraSharedWritePaths({ global = false } = {}) {
60064
+ return [ClaudecodeLanguageSettings.getSettablePaths({ global })];
60065
+ }
58871
60066
  constructor({ frontmatter, body, ...rest }) {
58872
60067
  if (rest.validate) {
58873
60068
  const result = ClaudecodeRuleFrontmatterSchema.safeParse(frontmatter);
@@ -59573,6 +60768,12 @@ const CursorRuleFrontmatterSchema = z.object({
59573
60768
  globs: z.optional(z.string()),
59574
60769
  alwaysApply: z.optional(z.boolean())
59575
60770
  });
60771
+ /**
60772
+ * Globs that match the whole project, and so say nothing `alwaysApply: true`
60773
+ * does not already say. Same pair the Cline and Qwen Code adapters treat as
60774
+ * universal.
60775
+ */
60776
+ const UNIVERSAL_GLOBS = /* @__PURE__ */ new Set(["**/*", "*"]);
59576
60777
  var CursorRule = class CursorRule extends ToolRule {
59577
60778
  frontmatter;
59578
60779
  body;
@@ -59601,7 +60802,10 @@ var CursorRule = class CursorRule extends ToolRule {
59601
60802
  const rawDescription = frontmatter.description;
59602
60803
  const description = typeof rawDescription === "string" ? rawDescription.replace(/\n+/g, " ").trim() : rawDescription;
59603
60804
  if (description) lines.push(dump({ description }, { lineWidth: -1 }).trimEnd());
59604
- if (frontmatter.globs !== void 0) lines.push(`globs: ${frontmatter.globs}`);
60805
+ if (frontmatter.globs !== void 0) {
60806
+ const globs = String(frontmatter.globs).replace(/[\r\n\u0085\u2028\u2029]+/g, " ").trim();
60807
+ lines.push(`globs: ${globs}`);
60808
+ }
59605
60809
  lines.push("---");
59606
60810
  lines.push("");
59607
60811
  if (body) lines.push(body);
@@ -59619,11 +60823,12 @@ var CursorRule = class CursorRule extends ToolRule {
59619
60823
  toRulesyncRule() {
59620
60824
  const targets = ["*"];
59621
60825
  const isAlways = this.frontmatter.alwaysApply === true;
59622
- const hasGlobs = this.frontmatter.globs && this.frontmatter.globs.trim() !== "";
59623
- let globs;
59624
- if (hasGlobs && this.frontmatter.globs) globs = this.frontmatter.globs.split(",").map((g) => g.trim()).filter((g) => g.length > 0);
59625
- else if (isAlways) globs = ["**/*"];
59626
- else globs = [];
60826
+ const rawGlobs = this.frontmatter.globs;
60827
+ const sourceGlobs = rawGlobs && rawGlobs.trim() !== "" ? rawGlobs.split(",").map((g) => g.trim()).filter((g) => g.length > 0) : [];
60828
+ const globs = sourceGlobs.length === 0 && isAlways ? ["**/*"] : sourceGlobs;
60829
+ let cursorGlobs;
60830
+ if (sourceGlobs.length > 0) cursorGlobs = sourceGlobs;
60831
+ else if (isAlways) cursorGlobs = [];
59627
60832
  return new RulesyncRule({
59628
60833
  frontmatter: {
59629
60834
  targets,
@@ -59633,7 +60838,7 @@ var CursorRule = class CursorRule extends ToolRule {
59633
60838
  cursor: {
59634
60839
  alwaysApply: this.frontmatter.alwaysApply,
59635
60840
  description: this.frontmatter.description,
59636
- globs: globs.length > 0 ? globs : void 0
60841
+ globs: cursorGlobs
59637
60842
  }
59638
60843
  },
59639
60844
  body: this.body,
@@ -59643,21 +60848,44 @@ var CursorRule = class CursorRule extends ToolRule {
59643
60848
  });
59644
60849
  }
59645
60850
  /**
59646
- * Resolve cursor globs with priority: cursor-specific > parent
59647
- * Returns comma-separated string for Cursor format, or undefined if no globs
59648
- * @param cursorSpecificGlobs - Cursor-specific globs (takes priority if defined)
59649
- * @param parentGlobs - Parent globs (used if cursorSpecificGlobs is undefined)
60851
+ * Resolve the Cursor `globs` string with priority: cursor-specific > parent.
60852
+ *
60853
+ * The two ways `cursorSpecificGlobs` can be absent are deliberately different
60854
+ * and must stay that way: `undefined` means "no Cursor-specific opinion", so
60855
+ * the canonical globs are used, while an explicit `[]` means "this rule has
60856
+ * no Cursor globs" and wins over them. Collapsing the two -- say, to
60857
+ * `cursorSpecificGlobs?.length ? … : parentGlobs` -- would put a universal
60858
+ * canonical glob back onto every Always Apply rule.
60859
+ *
60860
+ * A universal glob (see {@link UNIVERSAL_GLOBS}) is dropped outright when the
60861
+ * rule is Always Apply. `alwaysApply: true` already applies the rule
60862
+ * everywhere, Cursor's docs say globs are ignored once it is set, and
60863
+ * Cursor's own staff describe the two together as a semantic conflict that
60864
+ * some versions resolve by classifying the rule as a glob rule instead of an
60865
+ * Always one. Dropping it here is what heals, on the next generate, both a
60866
+ * `.rulesync` file an older version wrote with the invented universal glob in
60867
+ * `cursor.globs` and a rule hand-written with a universal canonical glob
60868
+ * beside `cursor.alwaysApply: true`. Specific globs are left alone: they say
60869
+ * something a flag cannot, so they are the author's to keep even alongside
60870
+ * it.
59650
60871
  */
59651
- static resolveCursorGlobs(cursorSpecificGlobs, parentGlobs) {
60872
+ static resolveCursorGlobs({ cursorSpecificGlobs, parentGlobs, alwaysApply }) {
59652
60873
  const targetGlobs = cursorSpecificGlobs !== void 0 ? cursorSpecificGlobs : parentGlobs;
59653
- return targetGlobs && targetGlobs.length > 0 ? targetGlobs.join(",") : void 0;
60874
+ if (!targetGlobs || targetGlobs.length === 0) return;
60875
+ if (alwaysApply && targetGlobs.every((glob) => UNIVERSAL_GLOBS.has(glob.trim()))) return;
60876
+ return targetGlobs.join(",");
59654
60877
  }
59655
60878
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true }) {
59656
60879
  const rulesyncFrontmatter = rulesyncRule.getFrontmatter();
60880
+ const alwaysApply = rulesyncFrontmatter.cursor?.alwaysApply;
59657
60881
  const cursorFrontmatter = {
59658
60882
  description: rulesyncFrontmatter.description,
59659
- globs: this.resolveCursorGlobs(rulesyncFrontmatter.cursor?.globs, rulesyncFrontmatter.globs),
59660
- alwaysApply: rulesyncFrontmatter.cursor?.alwaysApply ?? void 0
60883
+ globs: this.resolveCursorGlobs({
60884
+ cursorSpecificGlobs: rulesyncFrontmatter.cursor?.globs,
60885
+ parentGlobs: rulesyncFrontmatter.globs,
60886
+ alwaysApply: alwaysApply === true
60887
+ }),
60888
+ alwaysApply
59661
60889
  };
59662
60890
  const body = rulesyncRule.getBody();
59663
60891
  const newFileName = `${rulesyncRule.getRelativeFilePath().replace(/\.md$/, "")}.mdc`;
@@ -60982,15 +62210,14 @@ var KiroRule = class KiroRule extends ToolRule {
60982
62210
  * never wrote.
60983
62211
  * @see https://kiro.dev/docs/steering/
60984
62212
  */
60985
- static getNestedFilePatterns({ outputRoot }) {
60986
- const root = toPosixPath(outputRoot);
62213
+ static getNestedFilePatterns() {
60987
62214
  return {
60988
- include: [`${root}/**/${KIRO_NESTED_STEERING_FILE_NAME}`],
62215
+ include: [`**/${KIRO_NESTED_STEERING_FILE_NAME}`],
60989
62216
  ignore: [
60990
- `${root}/${KIRO_NESTED_STEERING_FILE_NAME}`,
60991
- `${root}/**/.*/**`,
60992
- ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `${root}/**/${dir}/**`),
60993
- ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
62217
+ KIRO_NESTED_STEERING_FILE_NAME,
62218
+ "**/.*/**",
62219
+ ...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `**/${dir}/**`),
62220
+ ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${dir}/**`)
60994
62221
  ]
60995
62222
  };
60996
62223
  }
@@ -61620,8 +62847,8 @@ var QwencodeRule = class QwencodeRule extends ToolRule {
61620
62847
  * The personal local context file lives under `.qwen/`, not at the project
61621
62848
  * root where the settable root path points, so override the deletion glob.
61622
62849
  */
61623
- static getLocalRootFileGlob({ outputRoot, fileName }) {
61624
- return join(outputRoot, QWENCODE_DIR, fileName);
62850
+ static getLocalRootFileGlob({ fileName }) {
62851
+ return posix.join(toPosixPath(QWENCODE_DIR), fileName);
61625
62852
  }
61626
62853
  };
61627
62854
  //#endregion
@@ -61648,11 +62875,8 @@ var ReasonixRule = class ReasonixRule extends ToolRule {
61648
62875
  * (same exclusions, import-only, project scope).
61649
62876
  * @see https://github.com/esengine/DeepSeek-Reasonix/blob/v1.18.0/docs/SESSION_MEMORY_RETRIEVAL.md
61650
62877
  */
61651
- static getNestedFilePatterns({ outputRoot }) {
61652
- return this.buildNestedFilePatterns({
61653
- outputRoot,
61654
- fileName: REASONIX_RULE_FILE_NAME
61655
- });
62878
+ static getNestedFilePatterns() {
62879
+ return this.buildNestedFilePatterns({ fileName: REASONIX_RULE_FILE_NAME });
61656
62880
  }
61657
62881
  /**
61658
62882
  * The subproject directory this rule scopes, or `undefined` for the root
@@ -61909,9 +63133,9 @@ var RooRule = class RooRule extends ToolRule {
61909
63133
  * generic directory is the only one the deletion sweep enumerates, because a
61910
63134
  * `rules-*` glob would also match mode rules a user wrote by hand.
61911
63135
  */
61912
- static getNestedFilePatterns({ outputRoot }) {
63136
+ static getNestedFilePatterns() {
61913
63137
  return {
61914
- include: [`${toPosixPath(outputRoot)}/${toPosixPath(ROO_DIR)}/rules-*/**/*.md`],
63138
+ include: [`${toPosixPath(ROO_DIR)}/rules-*/**/*.md`],
61915
63139
  ignore: []
61916
63140
  };
61917
63141
  }
@@ -61949,8 +63173,8 @@ var RooRule = class RooRule extends ToolRule {
61949
63173
  * Glob for the `separate-local-file` deletion; Roo reads `AGENTS.local.md`
61950
63174
  * at the project root, not under `.roo/` (mirrors rovodev).
61951
63175
  */
61952
- static getLocalRootFileGlob({ outputRoot, fileName }) {
61953
- return join(outputRoot, fileName);
63176
+ static getLocalRootFileGlob({ fileName }) {
63177
+ return fileName;
61954
63178
  }
61955
63179
  };
61956
63180
  //#endregion
@@ -62130,15 +63354,15 @@ var RovodevRule = class RovodevRule extends ToolRule {
62130
63354
  root: true
62131
63355
  })];
62132
63356
  },
62133
- getMirrorDeletionGlobs: ({ outputRoot }) => ({
62134
- primaryGlob: join(outputRoot, ROVODEV_DIR, ROVODEV_RULE_FILE_NAME),
62135
- mirrorGlob: join(outputRoot, ROVODEV_RULE_FILE_NAME)
63357
+ getMirrorDeletionGlobs: () => ({
63358
+ primaryGlob: posix.join(toPosixPath(ROVODEV_DIR), ROVODEV_RULE_FILE_NAME),
63359
+ mirrorGlob: ROVODEV_RULE_FILE_NAME
62136
63360
  })
62137
63361
  };
62138
63362
  }
62139
63363
  /** Glob for the `separate-local-file` deletion; rovodev writes it at project root, not under `.rovodev/`. */
62140
- static getLocalRootFileGlob({ outputRoot, fileName }) {
62141
- return join(outputRoot, fileName);
63364
+ static getLocalRootFileGlob({ fileName }) {
63365
+ return fileName;
62142
63366
  }
62143
63367
  };
62144
63368
  //#endregion
@@ -62278,11 +63502,8 @@ var VibeRule = class VibeRule extends ToolRule {
62278
63502
  * literally the same files.
62279
63503
  * @see https://github.com/mistralai/mistral-vibe/blob/main/vibe/core/config/harness_files/_harness_manager.py
62280
63504
  */
62281
- static getNestedFilePatterns({ outputRoot }) {
62282
- return this.buildNestedFilePatterns({
62283
- outputRoot,
62284
- fileName: AGENTSMD_RULE_FILE_NAME
62285
- });
63505
+ static getNestedFilePatterns() {
63506
+ return this.buildNestedFilePatterns({ fileName: AGENTSMD_RULE_FILE_NAME });
62286
63507
  }
62287
63508
  /**
62288
63509
  * The subproject directory this rule scopes, or `undefined` for the root file
@@ -63033,21 +64254,34 @@ const MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS = 10;
63033
64254
  * the tool will read" — can keep the two apart. A legacy root is a file
63034
64255
  * Rulesync reads but never writes, and the difference matters to them.
63035
64256
  */
63036
- const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob) => {
64257
+ const findFilesWithFallback = async (primaryFilePaths, alternativeRoots, buildAltGlob, outputRoot) => {
63037
64258
  if (primaryFilePaths.length > 0) return primaryFilePaths;
63038
- if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob));
64259
+ if (alternativeRoots) return await findFilesByGlobs(alternativeRoots.map(buildAltGlob), { cwd: outputRoot });
63039
64260
  return [];
63040
64261
  };
64262
+ /**
64263
+ * A project-root-relative glob for a file a tool keeps at a fixed path, joined
64264
+ * with `/` because a glob is always posix-separated.
64265
+ *
64266
+ * Relative because the root goes to `findFilesByGlobs` as `cwd` rather than into
64267
+ * the pattern: a project directory named `project(a)` or `project{a,b}` would
64268
+ * otherwise be read as a glob and match nothing at all — and on a `--delete`
64269
+ * sweep an empty result reads as "every source was removed", so rulesync would
64270
+ * delete generated files it can no longer regenerate and report success.
64271
+ */
64272
+ const rootRelativeGlob = (...segments) => posix.join(...segments.filter((segment) => segment !== void 0).map(toPosixPath));
63041
64273
  var RulesProcessor = class extends FeatureProcessor {
63042
64274
  toolTarget;
63043
64275
  simulateCommands;
63044
64276
  simulateSubagents;
63045
64277
  simulateSkills;
64278
+ language;
64279
+ deriveSubprojectPathFromGlobs;
63046
64280
  global;
63047
64281
  getFactory;
63048
64282
  skills;
63049
64283
  featureOptions;
63050
- constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
64284
+ constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, simulateCommands = false, simulateSubagents = false, simulateSkills = false, language, deriveSubprojectPathFromGlobs = false, global = false, getFactory = defaultGetFactory, skills, featureOptions, dryRun = false, logger }) {
63051
64285
  super({
63052
64286
  outputRoot,
63053
64287
  inputRoots,
@@ -63061,6 +64295,8 @@ var RulesProcessor = class extends FeatureProcessor {
63061
64295
  this.simulateCommands = simulateCommands;
63062
64296
  this.simulateSubagents = simulateSubagents;
63063
64297
  this.simulateSkills = simulateSkills;
64298
+ this.language = language;
64299
+ this.deriveSubprojectPathFromGlobs = deriveSubprojectPathFromGlobs;
63064
64300
  this.getFactory = getFactory;
63065
64301
  this.skills = skills;
63066
64302
  this.featureOptions = featureOptions;
@@ -63104,8 +64340,10 @@ var RulesProcessor = class extends FeatureProcessor {
63104
64340
  });
63105
64341
  this.applyRootRuleSections({
63106
64342
  toolRules,
63107
- factory
64343
+ factory,
64344
+ convertedRules
63108
64345
  });
64346
+ extraFiles.push(...await this.buildLanguageSettingsFiles());
63109
64347
  const outputFiles = [...toolRules, ...extraFiles];
63110
64348
  this.warnForOutputPathCollisions({
63111
64349
  outputFiles,
@@ -63163,7 +64401,8 @@ var RulesProcessor = class extends FeatureProcessor {
63163
64401
  outputRoot: this.outputRoot,
63164
64402
  instructions: instructionPaths,
63165
64403
  validate: true,
63166
- global: this.global
64404
+ global: this.global,
64405
+ logger: this.logger
63167
64406
  });
63168
64407
  return registered ? [registered] : [];
63169
64408
  }
@@ -63172,11 +64411,19 @@ var RulesProcessor = class extends FeatureProcessor {
63172
64411
  * reference and conventions sections to the root rule content. Mutates the
63173
64412
  * root rule in place.
63174
64413
  */
63175
- applyRootRuleSections({ toolRules, factory }) {
64414
+ applyRootRuleSections({ toolRules, factory, convertedRules }) {
63176
64415
  const { meta } = factory;
63177
64416
  const rootRule = toolRules.find((rule) => rule.isRoot());
63178
- if (!rootRule) return;
63179
- const newContent = this.generateReferenceSectionFromMeta(meta, toolRules) + (!meta.createsSeparateConventionsRule && meta.additionalConventions ? this.generateAdditionalConventionsSectionFromMeta(meta) : "") + rootRule.getFileContent();
64417
+ if (!rootRule) {
64418
+ this.appendLanguageBlockToRootSourceRules({ convertedRules });
64419
+ return;
64420
+ }
64421
+ const assembledContent = this.generateReferenceSectionFromMeta(meta, toolRules) + (!meta.createsSeparateConventionsRule && meta.additionalConventions ? this.generateAdditionalConventionsSectionFromMeta(meta) : "") + rootRule.getFileContent();
64422
+ const promptLanguage = this.getPromptBlockLanguage();
64423
+ const newContent = promptLanguage === void 0 ? assembledContent : appendLanguageBlock({
64424
+ content: assembledContent,
64425
+ language: promptLanguage
64426
+ });
63180
64427
  rootRule.setFileContent(newContent);
63181
64428
  const rootMirror = factory.class.getRootMirror?.();
63182
64429
  if (rootMirror && !this.global) toolRules.push(...rootMirror.getMirrorFiles({
@@ -63185,6 +64432,53 @@ var RulesProcessor = class extends FeatureProcessor {
63185
64432
  content: newContent
63186
64433
  }));
63187
64434
  }
64435
+ /**
64436
+ * The language delivered as a prompt block, or `undefined` when none is:
64437
+ * `language` is unset, or the target is Claude Code, which has a native
64438
+ * `language` setting (see {@link ClaudecodeLanguageSettings}) and so gets
64439
+ * no block in its root file.
64440
+ */
64441
+ getPromptBlockLanguage() {
64442
+ return this.isClaudecodeTarget() ? void 0 : this.language;
64443
+ }
64444
+ isClaudecodeTarget() {
64445
+ return this.toolTarget === "claudecode" || this.toolTarget === "claudecode-legacy";
64446
+ }
64447
+ /**
64448
+ * The language block for targets whose adapters never mark a ToolRule as
64449
+ * root: Cursor emits every rule as `.cursor/rules/*.mdc`, and the fixed-name
64450
+ * targets (Cline, Roo, Kiro, ...) file the `root: true` source beside the
64451
+ * others. The file produced from the `root: true` source is still the root
64452
+ * rule from the user's point of view, so it is the one that gets the block.
64453
+ * Nested rules never do. When several sources are marked `root: true`, only
64454
+ * the file built from the first one (in conversion order, which follows the
64455
+ * source order) carries the block: one instruction per target is the
64456
+ * contract, and a root-marking target gets exactly one as well.
64457
+ */
64458
+ appendLanguageBlockToRootSourceRules({ convertedRules }) {
64459
+ const language = this.getPromptBlockLanguage();
64460
+ if (language === void 0) return;
64461
+ const firstRootSource = convertedRules.find(({ rulesyncRule }) => rulesyncRule.getFrontmatter().root === true);
64462
+ if (firstRootSource === void 0) return;
64463
+ const { toolRule } = firstRootSource;
64464
+ toolRule.setFileContent(appendLanguageBlock({
64465
+ content: toolRule.getFileContent(),
64466
+ language
64467
+ }));
64468
+ }
64469
+ /**
64470
+ * Claude Code's native delivery of `language`: a patch to the settings file
64471
+ * instead of a prompt block. Empty for every other target and when
64472
+ * `language` is unset, so an unset key never touches the settings file.
64473
+ */
64474
+ async buildLanguageSettingsFiles() {
64475
+ if (this.language === void 0 || !this.isClaudecodeTarget()) return [];
64476
+ return [await ClaudecodeLanguageSettings.fromLanguage({
64477
+ outputRoot: this.outputRoot,
64478
+ language: this.language,
64479
+ global: this.global
64480
+ })];
64481
+ }
63188
64482
  buildSkillList(skillClass) {
63189
64483
  if (!this.skills) return [];
63190
64484
  const toolRelativeDirPath = skillClass.getSettablePaths({ global: this.global }).relativeDirPath;
@@ -63414,7 +64708,10 @@ As this project's AI coding tool, you must follow the additional conventions bel
63414
64708
  const toolRules = toolFiles.filter((file) => file instanceof ToolRule);
63415
64709
  const rulesyncRules = toolRules.map((toolRule) => {
63416
64710
  if (toolRule.isLocalRoot()) return toolRule.toLocalRootRulesyncRule({ targets: [this.toolTarget] });
63417
- return toolRule.toRulesyncRule();
64711
+ return this.withoutLanguageBlock({
64712
+ toolRule,
64713
+ rulesyncRule: toolRule.toRulesyncRule()
64714
+ });
63418
64715
  });
63419
64716
  const claimedBy = /* @__PURE__ */ new Map();
63420
64717
  for (const [index, rulesyncRule] of rulesyncRules.entries()) {
@@ -63430,6 +64727,33 @@ As this project's AI coding tool, you must follow the additional conventions bel
63430
64727
  return rulesyncRules;
63431
64728
  }
63432
64729
  /**
64730
+ * Drop the language block a previous `generate` appended, so that importing
64731
+ * a generated root file and generating again yields one block, not two.
64732
+ * Every imported rule is checked, not just root ones: Cursor and the
64733
+ * fixed-name targets import their root file as a non-root rule. The
64734
+ * `language` key itself lives in `rulesync.jsonc`, so nothing about the
64735
+ * detected language is carried into the rulesync rule — which is why the
64736
+ * strip is reported: a user who imports a file carrying the block and has
64737
+ * not set `language` would otherwise lose the instruction without a trace.
64738
+ * Once per file per run, since an import over several targets reads the
64739
+ * same root file for each of them.
64740
+ */
64741
+ withoutLanguageBlock({ toolRule, rulesyncRule }) {
64742
+ const body = rulesyncRule.getBody();
64743
+ const stripped = stripLanguageBlock(body);
64744
+ if (stripped === body) return rulesyncRule;
64745
+ const source = stripControlCharacters(join(toolRule.getRelativeDirPath(), toolRule.getRelativeFilePath()));
64746
+ warnOnceWithFallback(this.logger, `Removed the answer-language block rulesync appends from ${source} on import; it is not kept in .rulesync/rules/. Set "language" in rulesync.jsonc to keep generating it.`);
64747
+ return new RulesyncRule({
64748
+ outputRoot: rulesyncRule.getOutputRoot(),
64749
+ relativeDirPath: rulesyncRule.getRelativeDirPath(),
64750
+ relativeFilePath: rulesyncRule.getRelativeFilePath(),
64751
+ frontmatter: rulesyncRule.getFrontmatter(),
64752
+ body: stripped,
64753
+ validate: false
64754
+ });
64755
+ }
64756
+ /**
63433
64757
  * Load rulesync rule files from a single source-tree's `rules/` (and
63434
64758
  * `rules/.curated/`) subtree. `sourceTree` is the source tree itself
63435
64759
  * (e.g. `/repo/.rulesync` or `/repo/.rulesync.local`), NOT its parent.
@@ -63446,7 +64770,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
63446
64770
  const rulesyncOutputRoot = join(sourceTree, RULES_FEATURE_SUBDIR);
63447
64771
  const curatedOutputRoot = join(sourceTree, CURATED_RULES_FEATURE_SUBDIR);
63448
64772
  const [rulesDirExists, curatedDirExists] = await Promise.all([directoryExistsStrict(rulesyncOutputRoot), directoryExistsStrict(curatedOutputRoot)]);
63449
- const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([rulesDirExists ? findFilesByGlobs(join(rulesyncOutputRoot, "**", "*.md")) : [], curatedDirExists ? findFilesByGlobs(join(curatedOutputRoot, "**", "*.md")) : []]);
64773
+ const [discoveredFiles, discoveredCuratedFiles] = await Promise.all([rulesDirExists ? findFilesByGlobs("**/*.md", { cwd: rulesyncOutputRoot }) : [], curatedDirExists ? findFilesByGlobs("**/*.md", { cwd: curatedOutputRoot }) : []]);
63450
64774
  const files = [.../* @__PURE__ */ new Set([...discoveredFiles, ...discoveredCuratedFiles])];
63451
64775
  const localFiles = files.filter((file) => !relative(rulesyncOutputRoot, file).startsWith(`.curated${sep}`));
63452
64776
  const localRelativePathsByIdentity = groupSpellingsByCaseFoldedIdentity(localFiles.map((file) => relative(rulesyncOutputRoot, file)));
@@ -63483,7 +64807,8 @@ As this project's AI coding tool, you must follow the additional conventions bel
63483
64807
  const rule = await RulesyncRule.fromFile({
63484
64808
  outputRoot: treeParent,
63485
64809
  relativeDirPath: treeRulesDirPath,
63486
- relativeFilePath: sourceRelativeFilePath
64810
+ relativeFilePath: sourceRelativeFilePath,
64811
+ deriveSubprojectPathFromGlobs: this.deriveSubprojectPathFromGlobs
63487
64812
  });
63488
64813
  if (sourceRelativeFilePath === relativeFilePath) return rule;
63489
64814
  return new RulesyncRule({
@@ -63632,10 +64957,10 @@ As this project's AI coding tool, you must follow the additional conventions bel
63632
64957
  * Resolved once, up front, so the two blocks that need it do not depend
63633
64958
  * on each other's evaluation order.
63634
64959
  */
63635
- const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath)) : [];
64960
+ const primaryRootFilePaths = settablePaths.root ? await findFilesByGlobs(rootRelativeGlob(settablePaths.root.relativeDirPath ?? ".", settablePaths.root.relativeFilePath), { cwd: this.outputRoot }) : [];
63636
64961
  const rootToolRules = await (async () => {
63637
64962
  if (!settablePaths.root) return [];
63638
- const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, alt.relativeFilePath));
64963
+ const uniqueRootFilePaths = await findFilesWithFallback(primaryRootFilePaths, settablePaths.alternativeRoots, (alt) => rootRelativeGlob(alt.relativeDirPath, alt.relativeFilePath), this.outputRoot);
63639
64964
  if (forDeletion) return buildDeletionRulesFromPaths(uniqueRootFilePaths);
63640
64965
  return await buildImportRulesFromPaths(uniqueRootFilePaths);
63641
64966
  })();
@@ -63644,12 +64969,9 @@ As this project's AI coding tool, you must follow the additional conventions bel
63644
64969
  if (this.global || factory.meta.localRootMode !== "separate-local-file" || !factory.meta.localRootFileName) return [];
63645
64970
  const fileName = factory.meta.localRootFileName;
63646
64971
  const filePaths = await (async () => {
63647
- if (factory.class.getLocalRootFileGlob) return await findFilesByGlobs(factory.class.getLocalRootFileGlob({
63648
- outputRoot: this.outputRoot,
63649
- fileName
63650
- }));
64972
+ if (factory.class.getLocalRootFileGlob) return await findFilesByGlobs(factory.class.getLocalRootFileGlob({ fileName }), { cwd: this.outputRoot });
63651
64973
  if (!settablePaths.root) return [];
63652
- return await findFilesWithFallback(await findFilesByGlobs(join(this.outputRoot, settablePaths.root.relativeDirPath ?? ".", fileName)), settablePaths.alternativeRoots, (alt) => join(this.outputRoot, alt.relativeDirPath, fileName));
64974
+ return await findFilesWithFallback(await findFilesByGlobs(rootRelativeGlob(settablePaths.root.relativeDirPath ?? ".", fileName), { cwd: this.outputRoot }), settablePaths.alternativeRoots, (alt) => rootRelativeGlob(alt.relativeDirPath, fileName), this.outputRoot);
63653
64975
  })();
63654
64976
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
63655
64977
  return (await Promise.all(filePaths.map(async (filePath) => {
@@ -63672,15 +64994,15 @@ As this project's AI coding tool, you must follow the additional conventions bel
63672
64994
  const rootMirrorDeletionRules = await (async () => {
63673
64995
  const rootMirror = factory.class.getRootMirror?.();
63674
64996
  if (!forDeletion || this.global || !rootMirror) return [];
63675
- const { primaryGlob, mirrorGlob } = rootMirror.getMirrorDeletionGlobs({ outputRoot: this.outputRoot });
63676
- if ((await findFilesByGlobs(primaryGlob)).length === 0) return [];
63677
- const mirrorPaths = await findFilesByGlobs(mirrorGlob);
64997
+ const { primaryGlob, mirrorGlob } = rootMirror.getMirrorDeletionGlobs();
64998
+ if ((await findFilesByGlobs(primaryGlob, { cwd: this.outputRoot })).length === 0) return [];
64999
+ const mirrorPaths = await findFilesByGlobs(mirrorGlob, { cwd: this.outputRoot });
63678
65000
  return buildDeletionRulesFromPaths(mirrorPaths);
63679
65001
  })();
63680
65002
  const extraFixedToolRules = await (async () => {
63681
65003
  const extraFiles = factory.class.getExtraFixedFiles?.({ global: this.global });
63682
65004
  if (!extraFiles || extraFiles.length === 0) return [];
63683
- const filePaths = await findFilesByGlobs(extraFiles.map((file) => join(this.outputRoot, file.relativeDirPath, file.relativeFilePath)));
65005
+ const filePaths = await findFilesByGlobs(extraFiles.map((file) => rootRelativeGlob(file.relativeDirPath, file.relativeFilePath)), { cwd: this.outputRoot });
63684
65006
  if (filePaths.length === 0) return [];
63685
65007
  if (forDeletion) return buildDeletionRulesFromPaths(filePaths);
63686
65008
  return await Promise.all(filePaths.map((filePath) => {
@@ -63699,9 +65021,10 @@ As this project's AI coding tool, you must follow the additional conventions bel
63699
65021
  })();
63700
65022
  this.logger.debug(`Found ${extraFixedToolRules.length} extra fixed tool rule files`);
63701
65023
  const nestedToolRules = await (async () => {
63702
- const patterns = this.global ? void 0 : factory.class.getNestedFilePatterns?.({ outputRoot: this.outputRoot });
65024
+ const patterns = this.global ? void 0 : factory.class.getNestedFilePatterns?.();
63703
65025
  if (forDeletion || !patterns || patterns.include.length === 0) return [];
63704
65026
  const matchedPaths = await findFilesByGlobs(patterns.include, {
65027
+ cwd: this.outputRoot,
63705
65028
  type: "file",
63706
65029
  followSymbolicLinks: false,
63707
65030
  ignore: patterns.ignore
@@ -63732,7 +65055,10 @@ As this project's AI coding tool, you must follow the additional conventions bel
63732
65055
  const scannedPaths = [];
63733
65056
  const skippedPaths = [];
63734
65057
  for (const importOnlyRoot of importOnlyRoots) {
63735
- const matchedPaths = await findFilesByGlobs(join(this.outputRoot, importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), { type: "file" });
65058
+ const matchedPaths = await findFilesByGlobs(rootRelativeGlob(importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), {
65059
+ cwd: this.outputRoot,
65060
+ type: "file"
65061
+ });
63736
65062
  if (importOnlyRoot.onlyWhenRootAbsent === true && rootFilePath !== void 0) {
63737
65063
  skippedPaths.push(...matchedPaths);
63738
65064
  continue;
@@ -63751,7 +65077,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
63751
65077
  const nonRootToolRules = await (async () => {
63752
65078
  if (!settablePaths.nonRoot) return [];
63753
65079
  const nonRootOutputRoot = join(this.outputRoot, settablePaths.nonRoot.relativeDirPath);
63754
- const nonRootFilePaths = await findFilesByGlobs(join(nonRootOutputRoot, "**", `*.${factory.meta.extension}`));
65080
+ const nonRootFilePaths = await findFilesByGlobs(`**/*.${factory.meta.extension}`, { cwd: nonRootOutputRoot });
63755
65081
  if (forDeletion) return buildDeletionRulesFromPaths(nonRootFilePaths, {
63756
65082
  outputRootOverride: nonRootOutputRoot,
63757
65083
  relativeDirPathOverride: settablePaths.nonRoot.relativeDirPath
@@ -63894,7 +65220,7 @@ function resolveToolOutputRoot({ outputRoot, toolTarget, global }) {
63894
65220
  * `.rulesync/` files to disk. Rulesync file instances live in memory only.
63895
65221
  */
63896
65222
  async function convertFromTool(params) {
63897
- resetWarnedOnceMessages();
65223
+ resetRunWarningState();
63898
65224
  const packagingTarget = [params.fromTool, ...params.toTools].find(isPackagingToolTarget);
63899
65225
  if (packagingTarget) throw new Error(`Plugin packaging target '${packagingTarget}' is not supported by convert. Use import and generate with explicit plugin directories.`);
63900
65226
  const ctx = params;
@@ -63983,7 +65309,8 @@ function buildRulesStrategy(ctx) {
63983
65309
  toolTarget,
63984
65310
  global,
63985
65311
  dryRun,
63986
- logger
65312
+ logger,
65313
+ language: config.getLanguage()
63987
65314
  }),
63988
65315
  loadSource: (p) => p.loadToolFiles(),
63989
65316
  toRulesync: (p, files) => p.convertToolFilesToRulesyncFiles(files),
@@ -64373,6 +65700,9 @@ function createOrphanSweepPlan() {
64373
65700
  const resolved = resolve(path);
64374
65701
  return generatedPaths.has(resolved) || isInsideGeneratedTree(resolved);
64375
65702
  },
65703
+ isGeneratedExactly({ path }) {
65704
+ return generatedPaths.has(resolve(path));
65705
+ },
64376
65706
  rejectClaimed({ items, getPath }) {
64377
65707
  return items.filter((item) => !plan.isGenerated({ path: getPath(item) }));
64378
65708
  },
@@ -64606,13 +65936,18 @@ async function processDirFeatureGeneration(params) {
64606
65936
  getPath: (d) => d.getDirPath()
64607
65937
  }), toolDirs);
64608
65938
  const existingFlatFiles = await processor.loadToolFlatFilesToDelete();
64609
- return orphanDirCount + await processor.removeOrphanFlatFiles({
65939
+ const orphanFileCount = await processor.removeOrphanFlatFiles({
64610
65940
  existingFlatFiles: sweepPlan.rejectClaimed({
64611
65941
  items: existingFlatFiles,
64612
65942
  getPath: (d) => d.getFlatFilePath() ?? d.getDirPath()
64613
65943
  }),
64614
65944
  generatedDirs: toolDirs
64615
- }) > 0;
65945
+ });
65946
+ const orphanInDirCount = await processor.removeOrphanFilesInAiDirs({
65947
+ generatedDirs: toolDirs,
65948
+ isClaimed: (path) => sweepPlan.isGeneratedExactly({ path })
65949
+ });
65950
+ return orphanDirCount + orphanFileCount + orphanInDirCount > 0;
64616
65951
  } });
64617
65952
  return {
64618
65953
  count: totalCount,
@@ -64931,7 +66266,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
64931
66266
  async function generate(params) {
64932
66267
  const { config, logger } = params;
64933
66268
  resetRootShadowingWarnings({ logger });
64934
- resetWarnedOnceMessages();
66269
+ resetRunWarningState();
64935
66270
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
64936
66271
  toolTarget,
64937
66272
  outputRoot
@@ -65091,6 +66426,8 @@ async function generateRulesCore(params) {
65091
66426
  simulateCommands: config.getSimulateCommands(),
65092
66427
  simulateSubagents: config.getSimulateSubagents(),
65093
66428
  simulateSkills: config.getSimulateSkills(),
66429
+ language: config.getLanguage(),
66430
+ deriveSubprojectPathFromGlobs: config.getDeriveSubprojectPathFromGlobs(),
65094
66431
  skills,
65095
66432
  featureOptions: config.getFeatureOptions(toolTarget, "rules"),
65096
66433
  dryRun: config.isPreviewMode(),
@@ -65541,7 +66878,7 @@ function getToolOutputRoot({ config, tool }) {
65541
66878
  */
65542
66879
  async function importFromTool(params) {
65543
66880
  const { config, tool, logger } = params;
65544
- resetWarnedOnceMessages();
66881
+ resetRunWarningState();
65545
66882
  await assertPluginRootSafe({
65546
66883
  toolTarget: tool,
65547
66884
  outputRoot: getToolOutputRoot({
@@ -65863,6 +67200,6 @@ async function importChecksCore(params) {
65863
67200
  return writtenCount;
65864
67201
  }
65865
67202
  //#endregion
65866
- export { SHARED_USER_MANAGED_CONFIG_PATHS as $, MAX_FILE_SIZE as $t, groupSpellingsByCaseFoldedIdentity as A, DEPRECATED_FEATURE_REPLACEMENTS as An, isFileSystemError as At, RulesyncPermissions as B, removeFile as Bt, CLAUDECODE_SKILLS_DIR_PATH as C, RULESYNC_RULES_RELATIVE_DIR_PATH as Cn, createTempDirectory as Ct, FACTORYDROID_DIR as D, parseCommaSeparatedList as Dn, getFileSize as Dt, CODEXCLI_DIR as E, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as En, fileExists as Et, RulesyncSubagentFrontmatterSchema as F, stripControlCharactersKeepingLineFeeds as Fn, pathEscapesRoot as Ft, resolveRulesyncSourceWritePath as G, toPosixPath as Gt, RulesyncIgnore as H, removeTempDirectory as Ht, RulesyncSkill as I, stripHiddenCharacters as In, readFileContent as It, RulesyncCommandFrontmatterSchema as J, ALL_TOOL_TARGETS as Jt, parseJsonc as K, writeFileBuffer as Kt, RulesyncSkillFrontmatterSchema as L, readFileContentOrNull as Lt, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as M, truncateText as Mn, listDirectoryEntryNames as Mt, getLocalSkillDirNames as N, hasDeceptiveHiddenCharacters as Nn, listFilePathsRecursively as Nt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as O, ALL_FEATURES as On, getHomeDirectory as Ot, RulesyncSubagent as P, stripControlCharacters as Pn, listSubdirectoryNames as Pt, loadYaml as Q, CURATED_RULES_FEATURE_SUBDIR as Qt, RulesyncRule as R, removeDirectory as Rt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as S, RULESYNC_RELATIVE_DIR_PATH as Sn, checkPathTraversal as St, CODEXCLI_BASH_RULES_FILE_NAME as T, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Tn, ensureDir as Tt, RulesyncHooks as U, resolvePath as Ut, RulesyncMcp as V, removeFileStrict as Vt, getRulesyncSourceCandidates as W, runWithDirectoryRollback as Wt, RulesyncCheckFrontmatterSchema as X, PACKAGING_TOOL_TARGETS as Xt, RulesyncCheck as Y, ALL_TOOL_TARGETS_WITH_WILDCARD as Yt, stringifyFrontmatter as Z, ToolTargetSchema as Zt, QWENCODE_DIR as _, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as _n, CLIError as _t, getProcessorRegistryEntry as a, RULESYNC_CONFIG_SCHEMA_URL as an, ConfigFileSchema as at, CLAUDECODE_LOCAL_RULE_FILE_NAME as b, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as bn, assertTreeContainsNoSymlinks as bt, RulesProcessor as c, RULESYNC_HOOKS_FILE_NAME as cn, findControlCharacter as ct, displayWidthOf as d, RULESYNC_IGNORE_RELATIVE_FILE_PATH as dn, WarningCollectingLogger as dt, RULESYNC_AIIGNORE_FILE_NAME as en, SKILL_FILE_NAME as et, shortenToWidth as f, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as fn, fallbackLogger as ft, CommandsProcessor as g, RULESYNC_MCP_SCHEMA_URL as gn, withWarnOnceScope as gt, HooksProcessor as h, RULESYNC_MCP_RELATIVE_FILE_PATH as hn, resetWarnedOnceMessages as ht, inspectInputRoots as i, RULESYNC_CONFIG_RELATIVE_FILE_PATH as in, CONFLICTING_TARGET_PAIRS as it, AUGMENTCODE_DIR as j, formatError as jn, isSymlink as jt, caseFoldIdentity as k, ALL_FEATURES_WITH_WILDCARD as kn, isFileNotFoundError as kt, SubagentsProcessor as l, RULESYNC_HOOKS_LEGACY_FILE_NAME as ln, ConsoleLogger as lt, IgnoreProcessor as m, RULESYNC_MCP_LEGACY_FILE_NAME as mn, withFallbackLoggerTarget as mt, formatSourceLoadFailure as n, RULESYNC_CHECKS_RELATIVE_DIR_PATH as nn, mergeInputRootConfigs as nt, convertFromTool as o, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as on, GITIGNORE_DESTINATION_KEY as ot, McpProcessor as p, RULESYNC_MCP_FILE_NAME as pn, warnOnConflictingFlags as pt, RulesyncCommand as q, writeFileContent as qt, generate as r, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as rn, resolveEffectiveInputRoots as rt, isPackagingToolTarget as s, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as sn, SourceEntrySchema as st, importFromTool as t, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as tn, ConfigResolver as tt, SkillsProcessor as u, RULESYNC_HOOKS_RELATIVE_FILE_PATH as un, JsonLogger as ut, QWENCODE_LOCAL_RULE_FILE_NAME as v, RULESYNC_PERMISSIONS_FILE_NAME as vn, ErrorCodes as vt, ChecksProcessor as w, RULESYNC_SKILLS_RELATIVE_DIR_PATH as wn, directoryExists as wt, CLAUDECODE_MEMORIES_DIR_NAME as x, RULESYNC_PERMISSIONS_SCHEMA_URL as xn, assertWritablePathInsideRoot as xt, CLAUDECODE_DIR as y, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as yn, assertDirectoryIfExists as yt, RulesyncRuleFrontmatterSchema as z, removeDirectoryStrict as zt };
67203
+ export { loadYaml as $, ToolTargetSchema as $t, caseFoldIdentity as A, ALL_FEATURES as An, getHomeDirectory as At, RulesyncRuleFrontmatterSchema as B, removeDirectory as Bt, CLAUDECODE_DIR as C, RULESYNC_PERMISSIONS_SCHEMA_URL as Cn, assertWritablePathInsideRoot as Ct, CLAUDECODE_SKILLS_DIR_PATH as D, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Dn, ensureDir as Dt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as E, RULESYNC_SKILLS_RELATIVE_DIR_PATH as En, directoryExists as Et, RulesyncSubagent as F, hasDeceptiveHiddenCharacters as Fn, listFilePathsRecursively as Ft, getRulesyncSourceCandidates as G, resolvePath as Gt, RulesyncMcp as H, removeFile as Ht, RulesyncSubagentFrontmatterSchema as I, quoteForLog as In, listSubdirectoryNames as It, RulesyncCommand as J, writeFileBuffer as Jt, resolveRulesyncSourceWritePath as K, runWithDirectoryRollback as Kt, RulesyncSkill as L, stripControlCharacters as Ln, pathEscapesRoot as Lt, AUGMENTCODE_DIR as M, DEPRECATED_FEATURE_REPLACEMENTS as Mn, isFileSystemError as Mt, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as N, formatError as Nn, isSymlink as Nt, FACTORYDROID_DIR as O, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as On, fileExists as Ot, getLocalSkillDirNames as P, truncateText as Pn, listDirectoryEntryNames as Pt, stringifyFrontmatter as Q, PACKAGING_TOOL_TARGETS as Qt, RulesyncSkillFrontmatterSchema as R, stripControlCharactersKeepingLineFeeds as Rn, readFileContent as Rt, CODEXCLI_DIR as S, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as Sn, assertTreeContainsNoSymlinks as St, CLAUDECODE_MEMORIES_DIR_NAME as T, RULESYNC_RULES_RELATIVE_DIR_PATH as Tn, createTempDirectory as Tt, RulesyncIgnore as U, removeFileStrict as Ut, RulesyncPermissions as V, removeDirectoryStrict as Vt, RulesyncHooks as W, removeTempDirectory as Wt, RulesyncCheck as X, ALL_TOOL_TARGETS as Xt, RulesyncCommandFrontmatterSchema as Y, writeFileContent as Yt, RulesyncCheckFrontmatterSchema as Z, ALL_TOOL_TARGETS_WITH_WILDCARD as Zt, CommandsProcessor as _, RULESYNC_MCP_RELATIVE_FILE_PATH as _n, withWarnOnceScope as _t, getProcessorRegistryEntry as a, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as an, CONFLICTING_TARGET_PAIRS as at, ChecksProcessor as b, RULESYNC_PERMISSIONS_FILE_NAME as bn, applyFileMode as bt, RulesProcessor as c, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as cn, SourceEntrySchema as ct, ELLIPSIS_WIDTH as d, RULESYNC_HOOKS_LEGACY_FILE_NAME as dn, JsonLogger as dt, CURATED_RULES_FEATURE_SUBDIR as en, SHARED_USER_MANAGED_CONFIG_PATHS as et, displayWidthOf as f, RULESYNC_HOOKS_RELATIVE_FILE_PATH as fn, WarningCollectingLogger as ft, HooksProcessor as g, RULESYNC_MCP_LEGACY_FILE_NAME as gn, resetRunWarningState as gt, IgnoreProcessor as h, RULESYNC_MCP_FILE_NAME as hn, withFallbackLoggerTarget as ht, inspectInputRoots as i, RULESYNC_CHECKS_RELATIVE_DIR_PATH as in, resolveEffectiveInputRoots as it, groupSpellingsByCaseFoldedIdentity as j, ALL_FEATURES_WITH_WILDCARD as jn, isFileNotFoundError as jt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as k, parseCommaSeparatedList as kn, getFileSize as kt, SubagentsProcessor as l, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as ln, findControlCharacter as lt, McpProcessor as m, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as mn, warnOnConflictingFlags as mt, formatSourceLoadFailure as n, RULESYNC_AIIGNORE_FILE_NAME as nn, ConfigResolver as nt, convertFromTool as o, RULESYNC_CONFIG_RELATIVE_FILE_PATH as on, ConfigFileSchema as ot, shortenToWidth as p, RULESYNC_IGNORE_RELATIVE_FILE_PATH as pn, fallbackLogger as pt, parseJsonc as q, toPosixPath as qt, generate as r, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as rn, mergeInputRootConfigs as rt, isPackagingToolTarget as s, RULESYNC_CONFIG_SCHEMA_URL as sn, GITIGNORE_DESTINATION_KEY as st, importFromTool as t, MAX_FILE_SIZE as tn, SKILL_FILE_NAME as tt, SkillsProcessor as u, RULESYNC_HOOKS_FILE_NAME as un, ConsoleLogger as ut, QWENCODE_DIR as v, RULESYNC_MCP_SCHEMA_URL as vn, CLIError as vt, CLAUDECODE_LOCAL_RULE_FILE_NAME as w, RULESYNC_RELATIVE_DIR_PATH as wn, checkPathTraversal as wt, CODEXCLI_BASH_RULES_FILE_NAME as x, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as xn, assertDirectoryIfExists as xt, QWENCODE_LOCAL_RULE_FILE_NAME as y, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as yn, ErrorCodes as yt, RulesyncRule as z, stripHiddenCharacters as zn, readFileContentOrNull as zt };
65867
67204
 
65868
- //# sourceMappingURL=import-1-jjDDAm.js.map
67205
+ //# sourceMappingURL=import-5_n-Y8N6.js.map