rulesync 16.16.0 → 16.17.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.
@@ -34,6 +34,7 @@ let gray_matter = require("gray-matter");
34
34
  gray_matter = __toESM(gray_matter, 1);
35
35
  let js_yaml = require("js-yaml");
36
36
  let es_toolkit_object = require("es-toolkit/object");
37
+ let node_fs = require("node:fs");
37
38
  let node_crypto = require("node:crypto");
38
39
  let node_util = require("node:util");
39
40
  let smol_toml = require("smol-toml");
@@ -495,9 +496,22 @@ function isEnvTest() {
495
496
  }
496
497
  //#endregion
497
498
  //#region src/utils/file.ts
499
+ /**
500
+ * Whether a relative path leads out of the root it is relative to. Matching
501
+ * whole segments matters: a directory really named `..cache` relatively
502
+ * resolves to `..cache/file`, which a prefix test would report as an escape.
503
+ */
498
504
  function pathEscapesRoot(relativePath) {
499
505
  return relativePath === ".." || relativePath.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(relativePath);
500
506
  }
507
+ /** Whether a single path segment is a hidden (dot-prefixed) name. */
508
+ function isHiddenPathSegment(segment) {
509
+ return segment.startsWith(".") && segment !== "." && segment !== "..";
510
+ }
511
+ /** Split a path on both separators, so one predicate serves either platform. */
512
+ function splitPathSegments(filePath) {
513
+ return filePath.split(/[/\\]/);
514
+ }
501
515
  async function assertWritablePathInsideRoot(params) {
502
516
  const { rootPath, targetPath } = params;
503
517
  let existingPath = targetPath;
@@ -728,8 +742,45 @@ async function listDirectoryFiles(dir) {
728
742
  return [];
729
743
  }
730
744
  }
745
+ /** How many dot-prefixed segments a path has, used to prefer a named alias over a hidden one. */
746
+ function countHiddenSegments(filePath) {
747
+ return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
748
+ }
749
+ /**
750
+ * The real file a path denotes, posix-separated so it compares against the globby results
751
+ * that produce it. Two paths share an identity when they resolve to the very same file --
752
+ * a link beside its target, a link into a shared tree, or a cycle that walks back into an
753
+ * ancestor and yields the same file forty levels down.
754
+ */
755
+ async function realFileIdentity(filePath) {
756
+ try {
757
+ return toPosixPath(await (0, node_fs_promises.realpath)(filePath));
758
+ } catch {
759
+ return toPosixPath(filePath);
760
+ }
761
+ }
762
+ /**
763
+ * Pick the one path that represents a file among the paths that resolve to it.
764
+ *
765
+ * The path that walked through no link at all wins outright: it is already the real one,
766
+ * so it equals the file's identity. That keeps the real location of a file as the path
767
+ * callers see, rather than an alias that happens to sort first -- a directory link named
768
+ * `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a cycle must not replace
769
+ * `sub/note.md` with the same file reached back through the cycle.
770
+ * Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
771
+ * named one represents the entry rather than a hidden alias that a hidden-entry rule may
772
+ * then drop, taking the named path's content with it. `candidates` arrives in sorted
773
+ * order, so ties keep the first one deterministically.
774
+ */
775
+ function chooseRepresentative(candidates, identity) {
776
+ return candidates.reduce((best, candidate) => {
777
+ if (toPosixPath(best) === identity) return best;
778
+ if (toPosixPath(candidate) === identity) return candidate;
779
+ return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
780
+ });
781
+ }
731
782
  async function findFilesByGlobs(globs, options = {}) {
732
- const { type = "all", followSymbolicLinks = true, ignore } = options;
783
+ const { type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
733
784
  const globbyOptions = type === "file" ? {
734
785
  onlyFiles: true,
735
786
  onlyDirectories: false
@@ -744,23 +795,18 @@ async function findFilesByGlobs(globs, options = {}) {
744
795
  const results = (0, globby.globbySync)(normalizedGlobs, {
745
796
  absolute: true,
746
797
  followSymbolicLinks,
798
+ dot,
747
799
  ...ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {},
748
800
  ...globbyOptions
749
801
  });
750
- const seenRealPaths = /* @__PURE__ */ new Set();
751
- const deduped = [];
802
+ const candidatesByFile = /* @__PURE__ */ new Map();
752
803
  for (const result of results.toSorted()) {
753
- let realResult;
754
- try {
755
- realResult = await (0, node_fs_promises.realpath)(result);
756
- } catch {
757
- realResult = result;
758
- }
759
- if (seenRealPaths.has(realResult)) continue;
760
- seenRealPaths.add(realResult);
761
- deduped.push(result);
804
+ const identity = await realFileIdentity(result);
805
+ const candidates = candidatesByFile.get(identity);
806
+ if (candidates === void 0) candidatesByFile.set(identity, [result]);
807
+ else candidates.push(result);
762
808
  }
763
- return deduped;
809
+ return [...candidatesByFile.entries()].map(([identity, candidates]) => chooseRepresentative(candidates, identity)).toSorted();
764
810
  }
765
811
  async function removeDirectory(dirPath) {
766
812
  if ([
@@ -919,6 +965,25 @@ var CLIError = class extends Error {
919
965
  }
920
966
  };
921
967
  //#endregion
968
+ //#region src/utils/warned-once.ts
969
+ /**
970
+ * The messages a once-per-run warning has already emitted in this process.
971
+ * This lives in its own module, free of imports, so the vitest setup file can
972
+ * clear it between tests without pulling `logger.js` into every test's module
973
+ * graph (which would defeat the module mocks some of those tests install).
974
+ */
975
+ const warnedOnceMessages = /* @__PURE__ */ new Set();
976
+ /** Whether `message` has not been emitted yet; records it when it has not. */
977
+ function claimWarnOnce(message) {
978
+ if (warnedOnceMessages.has(message)) return false;
979
+ warnedOnceMessages.add(message);
980
+ return true;
981
+ }
982
+ /** Forget which warnings were already emitted, so each test starts silent. */
983
+ function resetWarnedOnceMessages() {
984
+ warnedOnceMessages.clear();
985
+ }
986
+ //#endregion
922
987
  //#region src/utils/logger.ts
923
988
  /**
924
989
  * Base class for shared verbose/silent state and configuration logic
@@ -1071,6 +1136,17 @@ const fallbackLogger = new ConsoleLogger();
1071
1136
  function warnWithFallback(logger, message) {
1072
1137
  (logger ?? fallbackLogger).warn(message);
1073
1138
  }
1139
+ /**
1140
+ * Emit a warning at most once per run. A single `generate` reads the same source
1141
+ * file once per enabled tool target, so a warning that describes the source
1142
+ * rather than the target would otherwise be printed a dozen identical times.
1143
+ * Diagnostics that name the file they are about qualify; anything whose text
1144
+ * varies with what the user should do next does not.
1145
+ */
1146
+ function warnOnceWithFallback(logger, message) {
1147
+ if (!claimWarnOnce(message)) return;
1148
+ warnWithFallback(logger, message);
1149
+ }
1074
1150
  //#endregion
1075
1151
  //#region src/utils/validation.ts
1076
1152
  /**
@@ -2015,8 +2091,7 @@ var AiFile = class {
2015
2091
  const fullPath = node_path.default.join(this.outputRoot, this.relativeDirPath, this.relativeFilePath);
2016
2092
  const resolvedFull = (0, node_path.resolve)(fullPath);
2017
2093
  const resolvedBase = (0, node_path.resolve)(this.outputRoot);
2018
- const rel = (0, node_path.relative)(resolvedBase, resolvedFull);
2019
- if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
2094
+ if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
2020
2095
  return fullPath;
2021
2096
  }
2022
2097
  getFileContent() {
@@ -2078,6 +2153,27 @@ var RulesyncFile = class extends AiFile {
2078
2153
  }
2079
2154
  };
2080
2155
  //#endregion
2156
+ //#region src/utils/control-characters.ts
2157
+ /**
2158
+ * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
2159
+ * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
2160
+ * line and paragraph separators, and the plain LRM/RLM marks. A name or value
2161
+ * copied out of an untrusted config file, a fetched repository, or a tool's own
2162
+ * settings file must never reach the terminal with these intact: they let the
2163
+ * text forge log lines, reorder what is printed around them, or inject escape
2164
+ * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
2165
+ * neutral characters beside them, so they go too — a diagnostic line is not the
2166
+ * place to preserve the typography of a right-to-left name.
2167
+ */
2168
+ const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
2169
+ /**
2170
+ * Removes every control character from `text` so it is safe to splice into a
2171
+ * log line or other terminal output.
2172
+ */
2173
+ function stripControlCharacters(text) {
2174
+ return text.replace(CONTROL_CHARACTERS_PATTERN, "");
2175
+ }
2176
+ //#endregion
2081
2177
  //#region src/utils/type-guards.ts
2082
2178
  /**
2083
2179
  * Type guard to check if a value is a plain object (Record<string, unknown>).
@@ -2201,7 +2297,7 @@ function parseFrontmatter(content, filePath) {
2201
2297
  let body;
2202
2298
  let hasFrontmatter;
2203
2299
  try {
2204
- const result = (0, gray_matter.default)(content);
2300
+ const result = (0, gray_matter.default)(content, {});
2205
2301
  frontmatter = result.data;
2206
2302
  body = result.content;
2207
2303
  hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
@@ -2215,6 +2311,104 @@ function parseFrontmatter(content, filePath) {
2215
2311
  hasFrontmatter
2216
2312
  };
2217
2313
  }
2314
+ /**
2315
+ * A top-level `key: value` entry. Nested entries are left alone deliberately:
2316
+ * the repair below rewrites a value's meaning, and the failure it exists for —
2317
+ * an unquoted sentence with a colon in it — is a `description`, which is always
2318
+ * top-level.
2319
+ */
2320
+ const TOP_LEVEL_ENTRY_PATTERN = /^([A-Za-z_][\w.-]*):[^\S\r\n]+(\S.*)$/;
2321
+ /** A plain scalar that already starts as some other YAML construct. */
2322
+ const YAML_CONSTRUCT_PREFIX_PATTERN = /^["'|>&*![{#]/;
2323
+ /**
2324
+ * Cut a plain scalar at its inline comment — whitespace followed by `#`.
2325
+ *
2326
+ * This has to happen before anything else looks at the value, or a line such as
2327
+ * `allowed-tools: Read # TODO: add Bash later` reads as needing repair and comes
2328
+ * back quoted with the comment inside it, which for a list of tool permissions
2329
+ * would grant what the comment had disabled. Scanning by hand rather than with
2330
+ * `/\s+#.*$/`: that pattern is unanchored, so a long run of spaces with no `#`
2331
+ * after it backtracks from every starting position, and a value padded with a
2332
+ * megabyte of spaces takes minutes to reject. This is linear in the value.
2333
+ */
2334
+ function stripInlineComment(rawValue) {
2335
+ for (let index = 1; index < rawValue.length; index++) if (rawValue[index] === "#" && /\s/.test(rawValue[index - 1] ?? "")) return rawValue.slice(0, index).trimEnd();
2336
+ return rawValue.trimEnd();
2337
+ }
2338
+ function repairFrontmatterLine(line) {
2339
+ const unchanged = {
2340
+ line,
2341
+ droppedComment: false
2342
+ };
2343
+ const carriageReturn = line.endsWith("\r") ? "\r" : "";
2344
+ const bareLine = carriageReturn === "" ? line : line.slice(0, -1);
2345
+ const match = TOP_LEVEL_ENTRY_PATTERN.exec(bareLine);
2346
+ if (!match) return unchanged;
2347
+ const [, key = "", rawValue = ""] = match;
2348
+ const value = stripInlineComment(rawValue);
2349
+ if (value === "") return unchanged;
2350
+ if (!/:(?:\s|$)/.test(value)) return unchanged;
2351
+ if (YAML_CONSTRUCT_PREFIX_PATTERN.test(value)) return unchanged;
2352
+ return {
2353
+ line: `${key}: ${JSON.stringify(value)}${carriageReturn}`,
2354
+ droppedComment: value !== rawValue.trimEnd()
2355
+ };
2356
+ }
2357
+ /**
2358
+ * Quote the unquoted scalars that make a frontmatter block unparseable, or
2359
+ * return `undefined` when there is nothing to repair. Only the frontmatter
2360
+ * block is rewritten; the body is passed through untouched.
2361
+ */
2362
+ function repairMalformedFrontmatterYaml(content) {
2363
+ const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
2364
+ if (!opening) return;
2365
+ const blockStart = opening[0].length;
2366
+ const closing = /\r?\n---/.exec(content.slice(blockStart));
2367
+ if (!closing) return;
2368
+ const blockEnd = blockStart + closing.index;
2369
+ const block = content.slice(blockStart, blockEnd);
2370
+ const repairedLines = block.split("\n").map(repairFrontmatterLine);
2371
+ const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
2372
+ if (repairedBlock === block) return;
2373
+ return {
2374
+ content: content.slice(0, blockStart) + repairedBlock + content.slice(blockEnd),
2375
+ droppedComment: repairedLines.some(({ droppedComment }) => droppedComment)
2376
+ };
2377
+ }
2378
+ /**
2379
+ * Parse frontmatter, retrying once with unquoted colon-bearing values quoted.
2380
+ *
2381
+ * Files authored for another client routinely carry YAML that only that
2382
+ * client's parser accepts — `description: Use this skill when: the user asks
2383
+ * about PDFs` is the case the Agent Skills client guide names. Without a retry
2384
+ * such a file is not merely reported, it is dropped: the lenient skill import
2385
+ * catches the parse error and skips the whole skill. The retry is deliberately
2386
+ * narrow — one pass, top-level entries only, and the original error is what
2387
+ * surfaces if it does not help, so a genuinely broken file still fails with the
2388
+ * message that describes what is actually wrong with it. A file with no closing
2389
+ * `---`, or one whose opening fence carries a language tag, is not repaired at
2390
+ * all: neither is a frontmatter block gray-matter would have read.
2391
+ *
2392
+ * @see https://agentskills.io/client-implementation/adding-skills-support
2393
+ */
2394
+ function parseFrontmatterWithYamlRepair(content, filePath, options = {}) {
2395
+ try {
2396
+ return parseFrontmatter(content, filePath);
2397
+ } catch (error) {
2398
+ const repaired = repairMalformedFrontmatterYaml(content);
2399
+ if (repaired === void 0) throw error;
2400
+ let result;
2401
+ try {
2402
+ result = parseFrontmatter(repaired.content, filePath);
2403
+ } catch {
2404
+ throw error;
2405
+ }
2406
+ if (options.quiet === true) return result;
2407
+ const commentNote = repaired.droppedComment ? " Text following a space and `#` was read as a YAML comment and left out of the value." : "";
2408
+ warnOnceWithFallback(void 0, `Recovered malformed YAML frontmatter in ${filePath === void 0 ? "the input" : stripControlCharacters(toPosixPath(filePath))} by quoting values that contain a colon.${commentNote} Quote them in the file itself so other tools can read it too.`);
2409
+ return result;
2410
+ }
2411
+ }
2218
2412
  //#endregion
2219
2413
  //#region src/features/checks/rulesync-check.ts
2220
2414
  const RulesyncCheckFrontmatterSchema = zod_mini.z.looseObject({
@@ -5884,8 +6078,474 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
5884
6078
  }
5885
6079
  };
5886
6080
  //#endregion
6081
+ //#region src/utils/concurrency.ts
6082
+ /**
6083
+ * Map over items with a bounded number of operations in flight.
6084
+ *
6085
+ * `Promise.all(items.map(...))` starts every operation at once, which is fine
6086
+ * for a handful of paths and not fine for a directory tree of unknown size: a
6087
+ * few thousand concurrent `realpath` calls queue on the libuv thread pool and
6088
+ * hold their closures alive while they wait. Results keep the input order.
6089
+ *
6090
+ * `withSemaphore` in `src/lib/github-utils.ts` bounds concurrency too, but it
6091
+ * wraps one call at a time: the caller still writes `Promise.all(items.map(…))`
6092
+ * around it, so every item's promise chain is allocated up front. This walks a
6093
+ * shared cursor with `limit` workers instead, so a list of unknown size costs
6094
+ * `limit` pending operations rather than one per item.
6095
+ */
6096
+ async function mapWithConcurrency({ items, limit, mapper }) {
6097
+ const results = Array.from({ length: items.length });
6098
+ let nextIndex = 0;
6099
+ const runWorker = async () => {
6100
+ while (nextIndex < items.length) {
6101
+ const index = nextIndex;
6102
+ nextIndex += 1;
6103
+ const item = items[index];
6104
+ if (item === void 0) continue;
6105
+ results[index] = await mapper(item);
6106
+ }
6107
+ };
6108
+ const workerCount = Math.max(1, Math.min(limit, items.length));
6109
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
6110
+ return results;
6111
+ }
6112
+ //#endregion
5887
6113
  //#region src/types/ai-dir.ts
5888
- var AiDir = class {
6114
+ /**
6115
+ * Directories that hold credentials. Excluding these protects something, so
6116
+ * their exclusion is reported rather than silent.
6117
+ */
6118
+ const NEVER_CARRIED_CREDENTIAL_DIR_NAMES = /* @__PURE__ */ new Set([
6119
+ ".ssh",
6120
+ ".aws",
6121
+ ".gnupg"
6122
+ ]);
6123
+ /**
6124
+ * Directories that are refused only when the path leaves the skill directory to
6125
+ * reach them. These are the per-application trees of a home directory, where
6126
+ * naming every credential file is a list always one release behind -- `gcloud`
6127
+ * alone writes `credentials.db` and `application_default_credentials.json`, and
6128
+ * `.config/anthropic/` holds an API key. A skill that ships a `.config/` of its
6129
+ * own still carries it: what is refused is a link that reaches the *user's*.
6130
+ *
6131
+ * A tool home is deliberately absent. `~/.claude/skills/` and `~/.codex/` hold
6132
+ * the global skills this feature exists to share, so refusing a link that
6133
+ * reaches one would refuse the ordinary case along with the bad one.
6134
+ */
6135
+ const NEVER_CARRIED_ESCAPING_DIR_NAMES = /* @__PURE__ */ new Set([
6136
+ ".config",
6137
+ ".local",
6138
+ ".azure",
6139
+ ".m2",
6140
+ ".terraform.d",
6141
+ ".docker",
6142
+ ".kube",
6143
+ "keychains"
6144
+ ]);
6145
+ /**
6146
+ * Whether an escaping real path passes through a directory only reachable by
6147
+ * escaping.
6148
+ *
6149
+ * Only the segments *past* the skill directory count. A global skill lives at
6150
+ * `~/.config/agents/skills/<name>` for Amp, Devin and Muse alike, so judging
6151
+ * the whole real path would refuse a link that never left the skills tree it
6152
+ * was already in -- the shared-directory case, reported as a credential.
6153
+ */
6154
+ function escapesIntoCredentialDir({ realDirPath, realFilePath }) {
6155
+ const normalize = (segment) => normalizePathSegment(segment.toLowerCase());
6156
+ const dirSegments = splitPathSegments(realDirPath).map(normalize);
6157
+ const fileSegments = splitPathSegments(realFilePath).map(normalize);
6158
+ let shared = 0;
6159
+ while (shared < dirSegments.length && shared < fileSegments.length && dirSegments[shared] === fileSegments[shared]) shared += 1;
6160
+ return (dirSegments.length - shared > 1 ? fileSegments : fileSegments.slice(shared)).some((segment) => NEVER_CARRIED_ESCAPING_DIR_NAMES.has(segment));
6161
+ }
6162
+ /**
6163
+ * Directories that are never part of a skill for the ordinary reasons: a
6164
+ * nested repository, or a build/cache tree. Leaving these out is what a user
6165
+ * expects, so it happens quietly.
6166
+ */
6167
+ const NEVER_CARRIED_NOISE_DIR_NAMES = /* @__PURE__ */ new Set([
6168
+ ".git",
6169
+ ".hg",
6170
+ ".svn",
6171
+ ".cache",
6172
+ ".venv",
6173
+ ".tox",
6174
+ ".mypy_cache",
6175
+ ".pytest_cache",
6176
+ ".ruff_cache",
6177
+ ".gradle",
6178
+ ".next",
6179
+ ".nuxt",
6180
+ ".turbo",
6181
+ ".parcel-cache",
6182
+ ".nyc_output",
6183
+ ".terraform"
6184
+ ]);
6185
+ /** Files that hold credentials. Compared lower-cased. */
6186
+ const NEVER_CARRIED_CREDENTIAL_FILE_NAMES = /* @__PURE__ */ new Set([
6187
+ ".npmrc",
6188
+ ".netrc",
6189
+ ".git-credentials",
6190
+ ".pgpass",
6191
+ ".pypirc",
6192
+ ".htpasswd",
6193
+ ".dockercfg",
6194
+ ".envrc"
6195
+ ]);
6196
+ /** Files that are local noise. Compared lower-cased. */
6197
+ const NEVER_CARRIED_NOISE_FILE_NAMES = /* @__PURE__ */ new Set([".ds_store"]);
6198
+ /** Credential files whose parent directory is otherwise ordinary content. */
6199
+ const NEVER_CARRIED_PATH_SUFFIXES = [
6200
+ ".docker/config.json",
6201
+ ".kube/config",
6202
+ ".config/gh/hosts.yml",
6203
+ ".config/gcloud/credentials.db",
6204
+ ".gem/credentials",
6205
+ "gcloud/application_default_credentials.json",
6206
+ ".codex/auth.json",
6207
+ ".gemini/oauth_creds.json"
6208
+ ];
6209
+ /** Whether a path ends in one of the credential files named above. */
6210
+ function endsWithNeverCarriedSuffix(filePath) {
6211
+ const posixPath = toPosixPath(filePath).toLowerCase();
6212
+ return NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`));
6213
+ }
6214
+ /**
6215
+ * `.env.<suffix>` spellings that are templates rather than real values.
6216
+ * Everything else matching `.env*` is treated as holding secrets, because
6217
+ * `.env.production` is no less sensitive than `.env` itself.
6218
+ */
6219
+ const ENV_TEMPLATE_SUFFIXES = /* @__PURE__ */ new Set([
6220
+ "example",
6221
+ "sample",
6222
+ "template",
6223
+ "dist",
6224
+ "defaults"
6225
+ ]);
6226
+ /**
6227
+ * Kernel pseudo-filesystems, matched against the resolved real path. A link
6228
+ * into one of these does not reach a file at all: `/proc/self/environ` reads
6229
+ * back the entire environment of the running process, API keys included, and
6230
+ * `stat` reports it as an ordinary file. Nothing a skill carries lives here.
6231
+ */
6232
+ const NEVER_CARRIED_REAL_PATH_ROOTS = [
6233
+ "/proc",
6234
+ "/sys",
6235
+ "/dev"
6236
+ ];
6237
+ /**
6238
+ * Whether a directory of this name is pruned during the walk, so it is never
6239
+ * descended into at all. Derived from the directory names above so the pruning
6240
+ * and the path check below cannot drift apart.
6241
+ */
6242
+ function isNeverCarriedDirName(dirName) {
6243
+ const normalized = normalizePathSegment(dirName.toLowerCase());
6244
+ return NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(normalized) || NEVER_CARRIED_NOISE_DIR_NAMES.has(normalized) || isCredentialFileName(normalized);
6245
+ }
6246
+ /**
6247
+ * Windows drops trailing dots and spaces from a name, so a file called `.env `
6248
+ * is written as `.env` once it lands in a tool directory there. Normalizing
6249
+ * before every comparison means the name is judged as what it becomes.
6250
+ */
6251
+ function normalizePathSegment(segment) {
6252
+ const normalized = segment.replace(/[\s.]+$/, "");
6253
+ return normalized === "" ? segment : normalized;
6254
+ }
6255
+ /**
6256
+ * Why a path reaches something that is never skill content, or `undefined`
6257
+ * when it does not.
6258
+ *
6259
+ * Carrying hidden entries means a secret sitting in a skill directory would be
6260
+ * copied into every enabled tool root, multiplying the places it can be
6261
+ * committed from, and a `.venv` would be copied file by file into each of them.
6262
+ * None of these names is ever skill content, so excluding them costs nothing.
6263
+ *
6264
+ * The check is applied to the resolved real path as well as the literal one:
6265
+ * the names are what makes an entry dangerous, and a symbolic link named
6266
+ * `vendor` pointing at `~/.aws` is exactly as dangerous as a directory called
6267
+ * `.aws`. Comparison is lower-cased because macOS and Windows resolve `.SSH`
6268
+ * and `.ssh` to the same file.
6269
+ */
6270
+ function classifyNeverCarried(relativePath) {
6271
+ const segments = toPosixPath(relativePath).toLowerCase().split("/").filter((segment) => segment !== "" && segment !== ".").map(normalizePathSegment);
6272
+ const fileName = segments.at(-1) ?? "";
6273
+ const posixPath = segments.join("/");
6274
+ if (segments.slice(0, -1).some((segment) => isCredentialFileName(segment))) return "credential";
6275
+ if (segments.some((segment) => NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(segment))) return "credential";
6276
+ if (segments.some((segment) => NEVER_CARRIED_NOISE_DIR_NAMES.has(segment))) return "noise";
6277
+ if (isCredentialFileName(fileName)) return "credential";
6278
+ if (NEVER_CARRIED_NOISE_FILE_NAMES.has(fileName)) return "noise";
6279
+ if (NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`))) return "credential";
6280
+ }
6281
+ /**
6282
+ * Whether a single path segment names a credential file, whether it is the file
6283
+ * itself or a directory somebody gave that name to.
6284
+ */
6285
+ function isCredentialFileName(segment) {
6286
+ const name = normalizePathSegment(segment.toLowerCase());
6287
+ if (NEVER_CARRIED_CREDENTIAL_FILE_NAMES.has(name)) return true;
6288
+ for (const base of [".env", ".envrc"]) {
6289
+ if (name === base) return true;
6290
+ if (name.startsWith(`${base}.`)) {
6291
+ const lastPiece = name.split(".").at(-1) ?? "";
6292
+ return !ENV_TEMPLATE_SUFFIXES.has(lastPiece);
6293
+ }
6294
+ }
6295
+ return false;
6296
+ }
6297
+ /** Whether a resolved real path points into a kernel pseudo-filesystem. */
6298
+ function isSystemPseudoPath(absolutePath) {
6299
+ const posixPath = toPosixPath(absolutePath);
6300
+ return NEVER_CARRIED_REAL_PATH_ROOTS.some((root) => posixPath === root || posixPath.startsWith(`${root}/`));
6301
+ }
6302
+ /** How many links a chain may be followed before it is treated as a loop. */
6303
+ const MAX_LINK_CHAIN_HOPS = 40;
6304
+ /**
6305
+ * Whether reaching a path goes through a kernel pseudo-filesystem, even when it
6306
+ * does not end in one.
6307
+ *
6308
+ * Asking `realpath` alone is not enough, and the entries it is not enough for
6309
+ * are the dangerous ones: `/proc/<pid>/fd/N`, `exe`, `cwd` and `root` are magic
6310
+ * links, so resolving them lands *outside* `/proc`, on whatever file the
6311
+ * process happens to hold open — a private key another program is reading right
6312
+ * now would come back as an ordinary path and be carried. Following the chain a
6313
+ * hop at a time, and checking each hop, is what sees the `/proc` in the middle.
6314
+ */
6315
+ async function resolvesThroughSystemPseudoPath(filePath) {
6316
+ let currentPath = (0, node_path.resolve)(filePath);
6317
+ let hops = 0;
6318
+ for (; hops < MAX_LINK_CHAIN_HOPS; hops++) {
6319
+ if (isSystemPseudoPath(currentPath)) return {
6320
+ throughPseudoPath: true,
6321
+ hops
6322
+ };
6323
+ try {
6324
+ if (isSystemPseudoPath(await (0, node_fs_promises.realpath)((0, node_path.dirname)(currentPath)))) return {
6325
+ throughPseudoPath: true,
6326
+ hops
6327
+ };
6328
+ } catch {}
6329
+ let linkStats;
6330
+ try {
6331
+ linkStats = await (0, node_fs_promises.lstat)(currentPath);
6332
+ } catch {
6333
+ return {
6334
+ throughPseudoPath: false,
6335
+ hops
6336
+ };
6337
+ }
6338
+ if (!linkStats.isSymbolicLink()) break;
6339
+ let target;
6340
+ try {
6341
+ target = await (0, node_fs_promises.readlink)(currentPath);
6342
+ } catch {
6343
+ return {
6344
+ throughPseudoPath: false,
6345
+ hops
6346
+ };
6347
+ }
6348
+ currentPath = (0, node_path.isAbsolute)(target) ? target : (0, node_path.resolve)((0, node_path.dirname)(currentPath), target);
6349
+ }
6350
+ return {
6351
+ throughPseudoPath: false,
6352
+ hops
6353
+ };
6354
+ }
6355
+ const MAX_CARRIED_FILES = 1e4;
6356
+ const MAX_CARRIED_DIRECTORIES = 1e4;
6357
+ /**
6358
+ * The bounds above limit what is *carried*; this one limits what is *looked at*.
6359
+ * A directory holding nothing but a few hundred thousand links to itself carries
6360
+ * no files and occupies no depth, and would still cost a `stat` apiece.
6361
+ */
6362
+ const MAX_CARRIED_ENTRIES_EXAMINED = 2e5;
6363
+ const MAX_CARRIED_BYTES = 104857600;
6364
+ /** How many `realpath` calls the carried-file filter keeps in flight. */
6365
+ const CARRIED_REALPATH_CONCURRENCY = 32;
6366
+ /** Sort directory entries by name so a walk of the same tree is reproducible. */
6367
+ function compareByName(left, right) {
6368
+ if (left.name === right.name) return 0;
6369
+ return left.name < right.name ? -1 : 1;
6370
+ }
6371
+ /**
6372
+ * Order the routes that cross the same number of symbolic links: the one with
6373
+ * the fewest hidden segments first, so a named alias represents a shared tree
6374
+ * rather than a hidden one that a hidden-entry rule then refuses, taking the
6375
+ * named route's content with it.
6376
+ */
6377
+ function comparePendingCarriedDirs(left, right) {
6378
+ if (left.hiddenSegments !== right.hiddenSegments) return left.hiddenSegments - right.hiddenSegments;
6379
+ if (left.depth !== right.depth) return left.depth - right.depth;
6380
+ if (left.dirPath === right.dirPath) return 0;
6381
+ return left.dirPath < right.dirPath ? -1 : 1;
6382
+ }
6383
+ /**
6384
+ * Collect the files under a carried directory, following symbolic links but
6385
+ * visiting each real directory exactly once, by its cheapest route.
6386
+ *
6387
+ * A glob walk cannot do this safely. Two links in one directory that both point
6388
+ * back at an ancestor double the paths walked per level, and the walker follows
6389
+ * them until the kernel's ELOOP limit (~40), so the path array alone exhausts
6390
+ * the heap long before anything reads a file — a depth bound only lowers the
6391
+ * exponent, while the base is whatever number of links the tree's author chose.
6392
+ * Remembering the real directories already visited removes the multiplication
6393
+ * itself: a cycle, and an alias for a directory already walked, both stop at the
6394
+ * entry that closes them.
6395
+ *
6396
+ * Which route represents a directory then matters, because the others are
6397
+ * dropped. The walk proceeds in rounds by the number of symbolic links crossed:
6398
+ * everything reachable without crossing one, then everything one link away, and
6399
+ * so on. A real location therefore always wins over an alias for it — at any
6400
+ * nesting depth, not just among siblings, which a depth-first walk could not
6401
+ * promise — and among aliases the named one wins over a hidden one.
6402
+ *
6403
+ * A broken link is skipped: it resolves to nothing to read.
6404
+ */
6405
+ async function walkCarriedFiles(dirPath, { skipHiddenRoutes = false } = {}) {
6406
+ const filePaths = [];
6407
+ const visitedRealDirPaths = /* @__PURE__ */ new Set();
6408
+ const truncations = /* @__PURE__ */ new Set();
6409
+ const unreadablePaths = /* @__PURE__ */ new Set();
6410
+ const pseudoPaths = /* @__PURE__ */ new Set();
6411
+ const depthStoppedRealDirPaths = /* @__PURE__ */ new Set();
6412
+ let deferredLinkedDirs = [];
6413
+ /** Whether the walk has hit a bound that ends it rather than one branch of it. */
6414
+ let examinedEntries = 0;
6415
+ const isFull = () => truncations.has("count") || truncations.has("directories") || truncations.has("entries");
6416
+ const addFile = (filePath) => {
6417
+ if (filePaths.length >= 1e4) {
6418
+ truncations.add("count");
6419
+ return;
6420
+ }
6421
+ filePaths.push(filePath);
6422
+ };
6423
+ /** Carry what a symbolic link names, or hold its directory for the next round. */
6424
+ const routeLinkedEntry = async (child, entryName) => {
6425
+ let targetStats;
6426
+ try {
6427
+ targetStats = await (0, node_fs_promises.stat)(child.dirPath);
6428
+ } catch {
6429
+ return;
6430
+ }
6431
+ if (targetStats.isFile()) {
6432
+ addFile(child.dirPath);
6433
+ return;
6434
+ }
6435
+ if (!targetStats.isDirectory() || isNeverCarriedDirName(entryName)) return;
6436
+ const { throughPseudoPath, hops } = await resolvesThroughSystemPseudoPath(child.dirPath);
6437
+ examinedEntries += hops;
6438
+ if (throughPseudoPath) {
6439
+ pseudoPaths.add(child.dirPath);
6440
+ return;
6441
+ }
6442
+ deferredLinkedDirs.push(child);
6443
+ };
6444
+ /** Walk one directory and everything below it that no symbolic link leads to. */
6445
+ const walkWithoutCrossingLinks = async (pending) => {
6446
+ if (isFull()) return;
6447
+ let realCurrentPath;
6448
+ try {
6449
+ realCurrentPath = await (0, node_fs_promises.realpath)(pending.dirPath);
6450
+ } catch {
6451
+ unreadablePaths.add(pending.dirPath);
6452
+ return;
6453
+ }
6454
+ if (visitedRealDirPaths.has(realCurrentPath)) return;
6455
+ if (isSystemPseudoPath(realCurrentPath)) {
6456
+ pseudoPaths.add(pending.dirPath);
6457
+ return;
6458
+ }
6459
+ if (pending.depth > 12) {
6460
+ depthStoppedRealDirPaths.add(realCurrentPath);
6461
+ return;
6462
+ }
6463
+ if (visitedRealDirPaths.size >= 1e4) {
6464
+ truncations.add("directories");
6465
+ return;
6466
+ }
6467
+ visitedRealDirPaths.add(realCurrentPath);
6468
+ let entries;
6469
+ try {
6470
+ entries = await (0, node_fs_promises.readdir)(pending.dirPath, { withFileTypes: true });
6471
+ } catch {
6472
+ unreadablePaths.add(pending.dirPath);
6473
+ return;
6474
+ }
6475
+ const realSubDirs = [];
6476
+ for (const entry of entries.toSorted(compareByName)) {
6477
+ if (isFull()) return;
6478
+ examinedEntries += 1;
6479
+ if (examinedEntries > 2e5) {
6480
+ truncations.add("entries");
6481
+ return;
6482
+ }
6483
+ if (skipHiddenRoutes && isHiddenPathSegment(entry.name)) continue;
6484
+ const entryPath = (0, node_path.join)(pending.dirPath, entry.name);
6485
+ const child = {
6486
+ dirPath: entryPath,
6487
+ depth: pending.depth + 1,
6488
+ hiddenSegments: pending.hiddenSegments + (isHiddenPathSegment(entry.name) ? 1 : 0)
6489
+ };
6490
+ if (entry.isFile()) {
6491
+ addFile(entryPath);
6492
+ continue;
6493
+ }
6494
+ if (entry.isDirectory()) {
6495
+ if (!isNeverCarriedDirName(entry.name)) realSubDirs.push(child);
6496
+ continue;
6497
+ }
6498
+ if (!entry.isSymbolicLink()) continue;
6499
+ await routeLinkedEntry(child, entry.name);
6500
+ }
6501
+ for (const realSubDir of realSubDirs) {
6502
+ if (isFull()) return;
6503
+ await walkWithoutCrossingLinks(realSubDir);
6504
+ }
6505
+ };
6506
+ let round = [{
6507
+ dirPath,
6508
+ depth: 0,
6509
+ hiddenSegments: 0
6510
+ }];
6511
+ while (round.length > 0 && !isFull()) {
6512
+ deferredLinkedDirs = [];
6513
+ for (const pending of round.toSorted(comparePendingCarriedDirs)) {
6514
+ if (isFull()) break;
6515
+ await walkWithoutCrossingLinks(pending);
6516
+ }
6517
+ round = deferredLinkedDirs;
6518
+ }
6519
+ for (const realDirPath of depthStoppedRealDirPaths) if (!visitedRealDirPaths.has(realDirPath)) {
6520
+ truncations.add("depth");
6521
+ break;
6522
+ }
6523
+ return {
6524
+ filePaths: filePaths.toSorted(),
6525
+ truncations,
6526
+ unreadablePaths: [...unreadablePaths],
6527
+ pseudoPaths: [...pseudoPaths]
6528
+ };
6529
+ }
6530
+ /** Render a set of refused or noteworthy paths for one warning line. */
6531
+ function formatReportedPaths(paths) {
6532
+ const sorted = [...paths].toSorted();
6533
+ const named = sorted.slice(0, 10).map((filePath) => stripControlCharacters(toPosixPath(filePath))).join(", ");
6534
+ const remaining = sorted.length - 10;
6535
+ return {
6536
+ count: sorted.length,
6537
+ list: `${named}${remaining > 0 ? `, and ${remaining} more` : ""}`
6538
+ };
6539
+ }
6540
+ /** "entry" or "entries", so the warnings below read as sentences. */
6541
+ function entryWord(count) {
6542
+ return count === 1 ? "entry" : "entries";
6543
+ }
6544
+ /** "resolves" or "resolve", to agree with the entry count it follows. */
6545
+ function resolveWord(count) {
6546
+ return count === 1 ? "resolves" : "resolve";
6547
+ }
6548
+ var AiDir = class AiDir {
5889
6549
  /**
5890
6550
  * @example "."
5891
6551
  */
@@ -5935,8 +6595,7 @@ var AiDir = class {
5935
6595
  const fullPath = node_path.default.join(this.outputRoot, this.relativeDirPath, this.dirName);
5936
6596
  const resolvedFull = (0, node_path.resolve)(fullPath);
5937
6597
  const resolvedBase = (0, node_path.resolve)(this.outputRoot);
5938
- const rel = (0, node_path.relative)(resolvedBase, resolvedFull);
5939
- if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
6598
+ if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
5940
6599
  return fullPath;
5941
6600
  }
5942
6601
  getMainFile() {
@@ -5962,9 +6621,227 @@ var AiDir = class {
5962
6621
  };
5963
6622
  }
5964
6623
  /**
6624
+ * A nested repository inside a carried directory is the one exclusion worth
6625
+ * reporting: unlike `.DS_Store`, it is there on purpose, and the tree it
6626
+ * points at is simply not reproduced on generate. Only the top level is
6627
+ * checked, which is where a submodule or a stray `git init` puts it, so the
6628
+ * check costs one stat per directory rather than a second walk. `fileExists`
6629
+ * is a bare `stat`, so it answers for a submodule pointer file and for a real
6630
+ * `.git` directory alike.
6631
+ */
6632
+ static async warnOnNestedGitDirectory(dirPath) {
6633
+ const gitEntryPath = (0, node_path.join)(dirPath, ".git");
6634
+ if (await fileExists(gitEntryPath)) warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(gitEntryPath))} with its directory: a nested repository is excluded, so the files it tracks are copied but its history is not.`);
6635
+ }
6636
+ /** Whether any segment of a relative path is dot-prefixed. */
6637
+ static hasHiddenSegment(relativePath) {
6638
+ return splitPathSegments(relativePath).some(isHiddenPathSegment);
6639
+ }
6640
+ /**
6641
+ * Whether the entry a path ends at is itself hidden, its ancestors aside.
6642
+ * A shared skill tree usually lives under a dot-directory, so an ancestor
6643
+ * says nothing about the file; its own name is what was chosen for it.
6644
+ */
6645
+ static hasHiddenName(relativePath) {
6646
+ return isHiddenPathSegment(splitPathSegments(relativePath).at(-1) ?? "");
6647
+ }
6648
+ /**
6649
+ * Drop the entries a skill directory must not carry.
6650
+ *
6651
+ * Three rules. `classifyNeverCarried` comes first, evaluated against the
6652
+ * resolved real path as well as the literal one: names that are never skill
6653
+ * content stay out however they are reached, so renaming a symbolic link
6654
+ * does not turn `~/.aws` into content a skill carries. Next, a real path
6655
+ * inside a kernel pseudo-filesystem is refused outright — that is process
6656
+ * state, not a file.
6657
+ *
6658
+ * The third concerns hidden entries a symbolic link reaches outside the
6659
+ * directory. Following symlinks out of a source tree is deliberate and
6660
+ * documented — it is how a shared skill is referenced from several projects
6661
+ * without being duplicated (issue #1707), and the trust boundary is the tree
6662
+ * you point Rulesync at. Carrying hidden entries changes what that costs,
6663
+ * though: one ordinary-looking link to a home directory would pull in every
6664
+ * dotfile under it, and those are the entries with credential value. What
6665
+ * decides is the name in the skill directory, not what the link resolves
6666
+ * through: a named file keeps its documented behavior even when the target
6667
+ * sits under a dot-directory such as `~/.dotfiles`, because somebody chose
6668
+ * that name. What the link resolves *to* still counts at the end of the path,
6669
+ * though — `notes.md` pointing at `~/.claude/.credentials.json` reaches a
6670
+ * file nobody named for a skill — so a hidden final segment is refused on
6671
+ * either side. Reaching outside is reported either way, since content from
6672
+ * outside the tree is about to be copied into every enabled tool root.
6673
+ *
6674
+ * A path that cannot be resolved is kept: `realpath` fails on a broken link
6675
+ * or a race, and neither is a reason to silently drop a file.
6676
+ */
6677
+ /** Report what a carried directory left out, once both walks have had their say. */
6678
+ static warnOnRefusedCarriedFiles(dirPath, carried) {
6679
+ const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
6680
+ if (carried.refusedCredentials.size > 0) {
6681
+ const { count, list } = formatReportedPaths(carried.refusedCredentials);
6682
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} named as a credential store: ${list}. A skill must not ship secrets; read them from the environment instead.`);
6683
+ }
6684
+ if (carried.refusedPseudoPaths.size > 0) {
6685
+ const { count, list } = formatReportedPaths(carried.refusedPseudoPaths);
6686
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a system pseudo-filesystem: ${list}. Those read back process state, not skill content.`);
6687
+ }
6688
+ if (carried.refusedNoiseAliases.size > 0) {
6689
+ const { count, list } = formatReportedPaths(carried.refusedNoiseAliases);
6690
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a directory a skill never carries: ${list}. A nested repository, or a build or cache tree, is the usual target.`);
6691
+ }
6692
+ if (carried.refusedEscapedHidden.size > 0) {
6693
+ const { count, list } = formatReportedPaths(carried.refusedEscapedHidden);
6694
+ warnOnceWithFallback(void 0, `Not carrying ${count} hidden ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Copy them into the directory if the skill really needs them.`);
6695
+ }
6696
+ if (carried.carriedFromOutside.size > 0) {
6697
+ const { count, list } = formatReportedPaths([...carried.carriedFromOutside].map((filePath) => {
6698
+ const realFilePath = carried.realFilePathByPath.get(filePath);
6699
+ return realFilePath === void 0 ? filePath : `${filePath} -> ${realFilePath}`;
6700
+ }));
6701
+ warnOnceWithFallback(void 0, `Carrying ${count} ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Their content is copied into every generated tool directory.`);
6702
+ }
6703
+ }
6704
+ static async filterCarriedFiles(dirPath, filePaths) {
6705
+ let realDirPath;
6706
+ try {
6707
+ realDirPath = await (0, node_fs_promises.realpath)(dirPath);
6708
+ } catch {
6709
+ realDirPath = (0, node_path.resolve)(dirPath);
6710
+ }
6711
+ const refusedCredentials = /* @__PURE__ */ new Set();
6712
+ const refusedPseudoPaths = /* @__PURE__ */ new Set();
6713
+ const refusedEscapedHidden = /* @__PURE__ */ new Set();
6714
+ const refusedNoiseAliases = /* @__PURE__ */ new Set();
6715
+ const carriedFromOutside = /* @__PURE__ */ new Set();
6716
+ const realFilePathByPath = /* @__PURE__ */ new Map();
6717
+ const verdicts = await mapWithConcurrency({
6718
+ items: filePaths,
6719
+ limit: CARRIED_REALPATH_CONCURRENCY,
6720
+ mapper: async (filePath) => {
6721
+ const literalPath = (0, node_path.relative)(dirPath, filePath);
6722
+ const literalReason = classifyNeverCarried(literalPath);
6723
+ if (literalReason !== void 0) {
6724
+ if (literalReason === "credential") refusedCredentials.add(filePath);
6725
+ return false;
6726
+ }
6727
+ let realFilePath;
6728
+ try {
6729
+ realFilePath = await (0, node_fs_promises.realpath)(filePath);
6730
+ } catch {
6731
+ return true;
6732
+ }
6733
+ realFilePathByPath.set(filePath, realFilePath);
6734
+ if (isSystemPseudoPath(realFilePath)) {
6735
+ refusedPseudoPaths.add(filePath);
6736
+ return false;
6737
+ }
6738
+ const realPath = (0, node_path.relative)(realDirPath, realFilePath);
6739
+ const realReason = classifyNeverCarried(realPath);
6740
+ if (realReason !== void 0) {
6741
+ if (realReason === "credential") refusedCredentials.add(filePath);
6742
+ else refusedNoiseAliases.add(filePath);
6743
+ return false;
6744
+ }
6745
+ if (!pathEscapesRoot(realPath)) return true;
6746
+ if ((await resolvesThroughSystemPseudoPath(filePath)).throughPseudoPath) {
6747
+ refusedPseudoPaths.add(filePath);
6748
+ return false;
6749
+ }
6750
+ if (endsWithNeverCarriedSuffix(realFilePath)) {
6751
+ refusedCredentials.add(filePath);
6752
+ return false;
6753
+ }
6754
+ if (escapesIntoCredentialDir({
6755
+ realDirPath,
6756
+ realFilePath
6757
+ })) {
6758
+ refusedCredentials.add(filePath);
6759
+ return false;
6760
+ }
6761
+ if (AiDir.hasHiddenSegment(literalPath) || AiDir.hasHiddenName(realPath)) {
6762
+ refusedEscapedHidden.add(filePath);
6763
+ return false;
6764
+ }
6765
+ carriedFromOutside.add(filePath);
6766
+ return true;
6767
+ }
6768
+ });
6769
+ return {
6770
+ filePaths: filePaths.filter((_filePath, index) => verdicts[index]),
6771
+ realFilePathByPath,
6772
+ refusedCredentials,
6773
+ refusedPseudoPaths,
6774
+ refusedEscapedHidden,
6775
+ refusedNoiseAliases,
6776
+ carriedFromOutside
6777
+ };
6778
+ }
6779
+ /**
6780
+ * Report what the walk had to leave behind, so a skill that silently lost
6781
+ * files says so rather than generating a directory that is quietly short.
6782
+ */
6783
+ static warnOnCarriedWalkLimits({ reportedDirPath, truncations, unreadablePaths }) {
6784
+ 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.`);
6785
+ 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.`);
6786
+ 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.`);
6787
+ if (truncations.has("entries")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} that come after the first ${MAX_CARRIED_ENTRIES_EXAMINED} looked at: a directory is walked over at most that many entries. A tree of symbolic links that lead back into it is the usual cause.`);
6788
+ if (unreadablePaths.length > 0) {
6789
+ const { count, list } = formatReportedPaths(unreadablePaths);
6790
+ warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that could not be read: ${list}. A permission the current user does not hold is the usual cause.`);
6791
+ }
6792
+ }
6793
+ /**
6794
+ * Walk the directory once more with the hidden routes pruned, and take the
6795
+ * files the first walk had to leave behind because the route that reached
6796
+ * them ran through a hidden directory. This can only add files, and only ones
6797
+ * a fully named route reaches.
6798
+ */
6799
+ static async recoverCarriedFilesFromNamedRoutes({ dirPath, carried, carriedPaths, carriedRealPaths, walk }) {
6800
+ const named = await walkCarriedFiles(dirPath, { skipHiddenRoutes: true });
6801
+ const recovered = await AiDir.filterCarriedFiles(dirPath, named.filePaths);
6802
+ for (const truncation of named.truncations) walk.truncations.add(truncation);
6803
+ for (const unreadablePath of named.unreadablePaths) if (!walk.unreadablePaths.includes(unreadablePath)) walk.unreadablePaths.push(unreadablePath);
6804
+ for (const pseudoPath of named.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
6805
+ for (const refused of recovered.refusedCredentials) carried.refusedCredentials.add(refused);
6806
+ for (const refused of recovered.refusedPseudoPaths) carried.refusedPseudoPaths.add(refused);
6807
+ for (const refused of recovered.refusedNoiseAliases) carried.refusedNoiseAliases.add(refused);
6808
+ for (const refused of recovered.refusedEscapedHidden) carried.refusedEscapedHidden.add(refused);
6809
+ for (const filePath of recovered.filePaths) {
6810
+ const realFilePath = recovered.realFilePathByPath.get(filePath) ?? filePath;
6811
+ if (carriedRealPaths.has(realFilePath)) continue;
6812
+ if (carriedPaths.length >= 1e4) {
6813
+ walk.truncations.add("count");
6814
+ break;
6815
+ }
6816
+ carriedRealPaths.add(realFilePath);
6817
+ carriedPaths.push(filePath);
6818
+ carried.realFilePathByPath.set(filePath, realFilePath);
6819
+ if (recovered.carriedFromOutside.has(filePath)) carried.carriedFromOutside.add(filePath);
6820
+ }
6821
+ for (const filePath of carried.refusedEscapedHidden) {
6822
+ const realFilePath = carried.realFilePathByPath.get(filePath) ?? filePath;
6823
+ if (carriedRealPaths.has(realFilePath)) carried.refusedEscapedHidden.delete(filePath);
6824
+ }
6825
+ }
6826
+ /**
5965
6827
  * Recursively collects all files from a directory, excluding the specified main file.
5966
6828
  * This is a common utility for loading additional files alongside the main file.
5967
6829
  *
6830
+ * Hidden entries are included. The directories this walks are skill trees,
6831
+ * whose specification says a skill directory "may contain any files and
6832
+ * directories beyond the required `SKILL.md`" — a `.env.example` beside the
6833
+ * scripts that read it is content, not noise, and dropping it silently on
6834
+ * both import and generate loses part of the skill. What is left out is the
6835
+ * set of entries that are never skill content — a nested repository's `.git`,
6836
+ * the macOS Finder's `.DS_Store`, credential stores, build and cache trees —
6837
+ * as decided by `classifyNeverCarried`. Whole directories from that set are
6838
+ * pruned during the walk too, so it never descends into them at all.
6839
+ *
6840
+ * The walk is bounded and cycle-aware — see `walkCarriedFiles` — because the
6841
+ * tree may contain symbolic links that somebody else chose.
6842
+ *
6843
+ * @see https://agentskills.io/specification
6844
+ *
5968
6845
  * @param outputRoot - The base directory path
5969
6846
  * @param relativeDirPath - The relative path to the directory containing the skill
5970
6847
  * @param dirName - The name of the directory
@@ -5973,14 +6850,57 @@ var AiDir = class {
5973
6850
  */
5974
6851
  static async collectOtherFiles(outputRoot, relativeDirPath, dirName, excludeFileName) {
5975
6852
  const dirPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName);
5976
- const filteredPaths = (await findFilesByGlobs((0, node_path.join)(dirPath, "**", "*"), { type: "file" })).filter((filePath) => (0, node_path.basename)(filePath) !== excludeFileName);
5977
- return await Promise.all(filteredPaths.map(async (filePath) => {
5978
- const fileBuffer = await readFileBuffer(filePath);
5979
- return {
5980
- relativeFilePathToDirPath: (0, node_path.relative)(dirPath, filePath),
5981
- fileBuffer
5982
- };
5983
- }));
6853
+ const walk = await walkCarriedFiles(dirPath);
6854
+ const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
6855
+ await AiDir.warnOnNestedGitDirectory(dirPath);
6856
+ const carried = await AiDir.filterCarriedFiles(dirPath, walk.filePaths);
6857
+ const carriedPaths = [...carried.filePaths];
6858
+ const carriedRealPaths = new Set(carriedPaths.map((filePath) => carried.realFilePathByPath.get(filePath) ?? filePath));
6859
+ if (carried.refusedEscapedHidden.size > 0) await AiDir.recoverCarriedFilesFromNamedRoutes({
6860
+ dirPath,
6861
+ carried,
6862
+ carriedPaths,
6863
+ carriedRealPaths,
6864
+ walk
6865
+ });
6866
+ for (const pseudoPath of walk.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
6867
+ AiDir.warnOnCarriedWalkLimits({
6868
+ reportedDirPath,
6869
+ truncations: walk.truncations,
6870
+ unreadablePaths: walk.unreadablePaths
6871
+ });
6872
+ AiDir.warnOnRefusedCarriedFiles(dirPath, carried);
6873
+ const filteredPaths = carriedPaths.toSorted().filter((filePath) => (0, node_path.basename)(filePath) !== excludeFileName);
6874
+ const files = [];
6875
+ let carriedBytes = 0;
6876
+ for (const [index, filePath] of filteredPaths.entries()) {
6877
+ const classifiedPath = carried.realFilePathByPath.get(filePath) ?? filePath;
6878
+ let fileHandle;
6879
+ try {
6880
+ fileHandle = await (0, node_fs_promises.open)(classifiedPath, node_fs.constants.O_RDONLY | (node_fs.constants.O_NOFOLLOW ?? 0));
6881
+ } catch (error) {
6882
+ warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
6883
+ continue;
6884
+ }
6885
+ try {
6886
+ const fileSize = (await fileHandle.stat()).size;
6887
+ if (carriedBytes + fileSize > 104857600) {
6888
+ 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.`);
6889
+ break;
6890
+ }
6891
+ const fileBuffer = await fileHandle.readFile();
6892
+ carriedBytes += fileBuffer.byteLength;
6893
+ files.push({
6894
+ relativeFilePathToDirPath: (0, node_path.relative)(dirPath, filePath),
6895
+ fileBuffer
6896
+ });
6897
+ } catch (error) {
6898
+ warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
6899
+ } finally {
6900
+ await fileHandle.close();
6901
+ }
6902
+ }
6903
+ return files;
5984
6904
  }
5985
6905
  };
5986
6906
  const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
@@ -6207,7 +7127,7 @@ var RulesyncSkill = class RulesyncSkill extends AiDir {
6207
7127
  const skillDirPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName);
6208
7128
  const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
6209
7129
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
6210
- const { frontmatter, body: content, hasFrontmatter } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
7130
+ const { frontmatter, body: content, hasFrontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
6211
7131
  if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${skillFilePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
6212
7132
  const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter);
6213
7133
  if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
@@ -6644,27 +7564,6 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
6644
7564
  return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
6645
7565
  }
6646
7566
  //#endregion
6647
- //#region src/utils/control-characters.ts
6648
- /**
6649
- * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
6650
- * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
6651
- * line and paragraph separators, and the plain LRM/RLM marks. A name or value
6652
- * copied out of an untrusted config file, a fetched repository, or a tool's own
6653
- * settings file must never reach the terminal with these intact: they let the
6654
- * text forge log lines, reorder what is printed around them, or inject escape
6655
- * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
6656
- * neutral characters beside them, so they go too — a diagnostic line is not the
6657
- * place to preserve the typography of a right-to-left name.
6658
- */
6659
- const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
6660
- /**
6661
- * Removes every control character from `text` so it is safe to splice into a
6662
- * log line or other terminal output.
6663
- */
6664
- function stripControlCharacters(text) {
6665
- return text.replace(CONTROL_CHARACTERS_PATTERN, "");
6666
- }
6667
- //#endregion
6668
7567
  //#region src/types/feature-processor.ts
6669
7568
  var FeatureProcessor = class {
6670
7569
  outputRoot;
@@ -11591,6 +12490,26 @@ function toolSkillSearchRoots(paths) {
11591
12490
  * https://agentskills.io/client-implementation/adding-skills-support
11592
12491
  */
11593
12492
  const AGENT_SKILLS_INTEROP_ROOTS = /* @__PURE__ */ new Set([toPosixPath(AGENTSMD_SKILLS_DIR_PATH), toPosixPath(AMP_SKILLS_GLOBAL_DIR)]);
12493
+ /**
12494
+ * The one spec violation both sides of the conversion report. Generation warns
12495
+ * about it before writing the file; import warns about it because a conformant
12496
+ * client would skip the skill entirely, and a user who never sees that has no
12497
+ * reason to fix it.
12498
+ */
12499
+ const EMPTY_SKILL_DESCRIPTION_VIOLATION = "`description` is required and must not be empty; conformant clients skip a skill without one";
12500
+ /**
12501
+ * Report a skill read from disk whose `description` is empty. This lives on the
12502
+ * read rather than on any one tool class because several targets share one
12503
+ * `.agents/skills` tree: which of them reports the skill must not depend on
12504
+ * which target the user happened to enable. Every loader calls it — the two
12505
+ * directory loaders (`loadSkillDirContent`, `SimulatedSkill.fromDirDefault`)
12506
+ * and the flat-file one — so the form a tool stores its skills in does not
12507
+ * decide whether the problem is reported either.
12508
+ */
12509
+ function warnOnEmptyLoadedDescription({ skillFilePath, description }) {
12510
+ if (typeof description !== "string" || description.length > 0) return;
12511
+ warnOnceWithFallback(void 0, `${stripControlCharacters(toPosixPath(skillFilePath))}: ${EMPTY_SKILL_DESCRIPTION_VIOLATION}. Rulesync imports it anyway so the content is not lost; fill it in before relying on this skill.`);
12512
+ }
11594
12513
  function isAgentSkillsInteropRoot(relativeDirPath) {
11595
12514
  return AGENT_SKILLS_INTEROP_ROOTS.has(toPosixPath(relativeDirPath));
11596
12515
  }
@@ -11703,7 +12622,11 @@ var ToolSkill = class extends AiDir {
11703
12622
  const skillDirPath = (0, node_path.join)(outputRoot, actualRelativeDirPath, dirName);
11704
12623
  const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
11705
12624
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
11706
- const { frontmatter, body: content } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
12625
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
12626
+ warnOnEmptyLoadedDescription({
12627
+ skillFilePath,
12628
+ description: frontmatter.description
12629
+ });
11707
12630
  const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
11708
12631
  return {
11709
12632
  outputRoot,
@@ -11828,6 +12751,10 @@ function toSpecConformantAgentSkillFields(section, { coerceMetadata = true } = {
11828
12751
  ...allowedTools !== void 0 && allowedTools.length > 0 && { "allowed-tools": allowedTools }
11829
12752
  };
11830
12753
  }
12754
+ /** The `SKILL.md` a diagnostic should point at, in the scope it is written to. */
12755
+ function agentSkillFilePath({ outputRoot, relativeDirPath, dirName }) {
12756
+ return (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
12757
+ }
11831
12758
  /**
11832
12759
  * Collect the normative violations the Agent Skills spec defines for a skill
11833
12760
  * about to be written. These are reported as warnings rather than errors:
@@ -11854,7 +12781,7 @@ function collectAgentSkillViolations({ frontmatter, dirName, sourceAllowedTools
11854
12781
  if (!NAME_PATTERN.test(name)) violations.push(`\`name\` "${name}" must contain only lowercase letters, digits and single hyphens, with no leading, trailing or consecutive hyphens`);
11855
12782
  if (name !== dirName) violations.push(`\`name\` "${name}" must match its parent directory name "${dirName}"; conformant clients require them to be equal`);
11856
12783
  }
11857
- if (description.length === 0) violations.push("`description` is required and must not be empty; conformant clients skip a skill without one");
12784
+ if (description.length === 0) violations.push(EMPTY_SKILL_DESCRIPTION_VIOLATION);
11858
12785
  else if (description.length > DESCRIPTION_MAX_LENGTH) violations.push(`\`description\` is ${description.length} characters; the Agent Skills spec allows at most ${DESCRIPTION_MAX_LENGTH}`);
11859
12786
  const { compatibility } = frontmatter;
11860
12787
  if (typeof compatibility !== "string" && compatibility !== void 0) violations.push("`compatibility` must be a string; the Agent Skills spec does not allow a mapping here");
@@ -11973,12 +12900,16 @@ var AgentsSkillsSkill = class AgentsSkillsSkill extends ToolSkill {
11973
12900
  * directory rather than a same-named project path.
11974
12901
  */
11975
12902
  static reportSpecViolations({ outputRoot, relativeDirPath, dirName, frontmatter, sourceAllowedTools, logger }) {
11976
- const skillPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
12903
+ const skillPath = agentSkillFilePath({
12904
+ outputRoot,
12905
+ relativeDirPath,
12906
+ dirName
12907
+ });
11977
12908
  for (const violation of collectAgentSkillViolations({
11978
12909
  frontmatter,
11979
12910
  dirName,
11980
12911
  sourceAllowedTools
11981
- })) warnWithFallback(logger, `${skillPath}: ${violation}`);
12912
+ })) warnWithFallback(logger, `${stripControlCharacters(toPosixPath(skillPath))}: ${violation}`);
11982
12913
  }
11983
12914
  static isTargetedByRulesyncSkill(rulesyncSkill) {
11984
12915
  const targets = rulesyncSkill.getFrontmatter().targets;
@@ -38575,9 +39506,13 @@ var SimulatedSkill = class extends ToolSkill {
38575
39506
  const skillDirPath = (0, node_path.join)(outputRoot, actualRelativeDirPath, dirName);
38576
39507
  const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
38577
39508
  if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
38578
- const { frontmatter, body: content } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
39509
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
38579
39510
  const result = SimulatedSkillFrontmatterSchema.safeParse(frontmatter);
38580
39511
  if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
39512
+ warnOnEmptyLoadedDescription({
39513
+ skillFilePath,
39514
+ description: result.data.description
39515
+ });
38581
39516
  const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
38582
39517
  return {
38583
39518
  outputRoot,
@@ -42105,7 +43040,7 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
42105
43040
  }
42106
43041
  static async fromFlatFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
42107
43042
  const filePath = (0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath);
42108
- const { frontmatter, body } = parseFrontmatter(await readFileContent(filePath), filePath);
43043
+ const { frontmatter, body } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath);
42109
43044
  const result = KimiCodeFlatSkillFrontmatterSchema.safeParse(frontmatter);
42110
43045
  if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
42111
43046
  const fileName = (0, node_path.basename)(relativeFilePath, (0, node_path.extname)(relativeFilePath));
@@ -42115,6 +43050,10 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
42115
43050
  name: result.data.name ?? fileName,
42116
43051
  description: result.data.description ?? firstBodyLine?.slice(0, 240) ?? "No description provided."
42117
43052
  };
43053
+ warnOnEmptyLoadedDescription({
43054
+ skillFilePath: filePath,
43055
+ description: normalizedFrontmatter.description
43056
+ });
42118
43057
  return new KimiCodeSkill({
42119
43058
  outputRoot,
42120
43059
  relativeDirPath,
@@ -43118,7 +44057,7 @@ var ReasonixSkill = class ReasonixSkill extends ToolSkill {
43118
44057
  static async isDirOwned({ outputRoot, relativeDirPath, dirName }) {
43119
44058
  const skillFilePath = (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
43120
44059
  try {
43121
- const { frontmatter } = parseFrontmatter(await readFileContent(skillFilePath), skillFilePath);
44060
+ const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath, { quiet: true });
43122
44061
  return frontmatter["runAs"] !== REASONIX_SUBAGENT_RUN_AS;
43123
44062
  } catch {
43124
44063
  return true;
@@ -43487,8 +44426,7 @@ var TaktSkill = class TaktSkill extends ToolSkill {
43487
44426
  const fullPath = (0, node_path.join)(this.outputRoot, this.relativeDirPath);
43488
44427
  const resolvedFull = (0, node_path.resolve)(fullPath);
43489
44428
  const resolvedBase = (0, node_path.resolve)(this.outputRoot);
43490
- const rel = (0, node_path.relative)(resolvedBase, resolvedFull);
43491
- if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
44429
+ if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
43492
44430
  return fullPath;
43493
44431
  }
43494
44432
  getRelativePathFromCwd() {
@@ -48455,7 +49393,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
48455
49393
  const paths = this.getSettablePaths({ global });
48456
49394
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
48457
49395
  const fileContent = await readFileContent(filePath);
48458
- const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
49396
+ const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(fileContent, filePath);
48459
49397
  const result = ReasonixSubagentFrontmatterSchema.safeParse(frontmatter);
48460
49398
  if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
48461
49399
  return new ReasonixSubagent({
@@ -48484,7 +49422,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
48484
49422
  static async isFileOwned({ outputRoot, relativeDirPath, relativeFilePath }) {
48485
49423
  const filePath = (0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath);
48486
49424
  try {
48487
- const { frontmatter } = parseFrontmatter(await readFileContent(filePath), filePath);
49425
+ const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath, { quiet: true });
48488
49426
  return frontmatter["runAs"] === REASONIX_SUBAGENT_RUN_AS;
48489
49427
  } catch {
48490
49428
  return false;
@@ -55878,6 +56816,7 @@ function resolveToolOutputRoot({ outputRoot, toolTarget, global }) {
55878
56816
  * `.rulesync/` files to disk. Rulesync file instances live in memory only.
55879
56817
  */
55880
56818
  async function convertFromTool(params) {
56819
+ resetWarnedOnceMessages();
55881
56820
  const packagingTarget = [params.fromTool, ...params.toTools].find(isPackagingToolTarget);
55882
56821
  if (packagingTarget) throw new Error(`Plugin packaging target '${packagingTarget}' is not supported by convert. Use import and generate with explicit plugin directories.`);
55883
56822
  const ctx = params;
@@ -56807,6 +57746,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
56807
57746
  async function generate(params) {
56808
57747
  const { config, logger } = params;
56809
57748
  resetRootShadowingWarnings({ logger });
57749
+ resetWarnedOnceMessages();
56810
57750
  for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
56811
57751
  toolTarget,
56812
57752
  outputRoot
@@ -57366,6 +58306,7 @@ function getToolOutputRoot({ config, tool }) {
57366
58306
  */
57367
58307
  async function importFromTool(params) {
57368
58308
  const { config, tool, logger } = params;
58309
+ resetWarnedOnceMessages();
57369
58310
  await assertPluginRootSafe({
57370
58311
  toolTarget: tool,
57371
58312
  outputRoot: getToolOutputRoot({
@@ -58317,6 +59258,12 @@ Object.defineProperty(exports, "parseJsonc", {
58317
59258
  return parseJsonc$8;
58318
59259
  }
58319
59260
  });
59261
+ Object.defineProperty(exports, "pathEscapesRoot", {
59262
+ enumerable: true,
59263
+ get: function() {
59264
+ return pathEscapesRoot;
59265
+ }
59266
+ });
58320
59267
  Object.defineProperty(exports, "readFileContent", {
58321
59268
  enumerable: true,
58322
59269
  get: function() {
@@ -58359,6 +59306,12 @@ Object.defineProperty(exports, "removeTempDirectory", {
58359
59306
  return removeTempDirectory;
58360
59307
  }
58361
59308
  });
59309
+ Object.defineProperty(exports, "resetWarnedOnceMessages", {
59310
+ enumerable: true,
59311
+ get: function() {
59312
+ return resetWarnedOnceMessages;
59313
+ }
59314
+ });
58362
59315
  Object.defineProperty(exports, "resolveEffectiveInputRoots", {
58363
59316
  enumerable: true,
58364
59317
  get: function() {