rulesync 16.18.0 → 16.19.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.
@@ -30,16 +30,222 @@ let node_os = require("node:os");
30
30
  node_os = __toESM(node_os, 1);
31
31
  let es_toolkit = require("es-toolkit");
32
32
  let globby = require("globby");
33
+ let node_async_hooks = require("node:async_hooks");
34
+ let node_util = require("node:util");
33
35
  let gray_matter = require("gray-matter");
34
36
  gray_matter = __toESM(gray_matter, 1);
35
37
  let js_yaml = require("js-yaml");
36
38
  let es_toolkit_object = require("es-toolkit/object");
37
39
  let node_fs = require("node:fs");
38
40
  let node_crypto = require("node:crypto");
39
- let node_util = require("node:util");
40
41
  let smol_toml = require("smol-toml");
41
42
  smol_toml = __toESM(smol_toml, 1);
42
43
  let _toon_format_toon = require("@toon-format/toon");
44
+ //#region src/utils/control-characters.ts
45
+ /**
46
+ * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
47
+ * introducer U+009B), the bidirectional overrides and isolates, the Unicode
48
+ * line and paragraph separators, and the plain LRM/RLM/ALM marks. A name or
49
+ * value copied out of an untrusted config file, a fetched repository, or a
50
+ * tool's own settings file must never reach the terminal with these intact:
51
+ * they let the text forge log lines, reorder what is printed around them, or
52
+ * inject escape sequences. LRM, RLM and the Arabic letter mark open no bidi
53
+ * scope of their own, but they still reorder the neutral characters beside
54
+ * them, so they go too — a diagnostic line is not the place to preserve the
55
+ * typography of a right-to-left name.
56
+ */
57
+ const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
58
+ /**
59
+ * Removes every control character from `text` so it is safe to splice into a
60
+ * log line or other terminal output.
61
+ */
62
+ function stripControlCharacters(text) {
63
+ return text.replace(CONTROL_CHARACTERS_PATTERN, "");
64
+ }
65
+ /**
66
+ * Removes every control character from `text` except the line feed, so a
67
+ * message written to be read over several lines still is.
68
+ *
69
+ * `stripControlCharacters` takes newlines out because a diagnostic is one line
70
+ * and a name that carries one can forge a second. An error message is not: a
71
+ * lock file names the process holding it over several lines, and the MCP
72
+ * `generate` failure lists one unreadable source per line. The carriage return
73
+ * still goes, since on its own it paints over the line already written — which
74
+ * is why this splits on the line feed and strips each line rather than carrying
75
+ * a second character class that has to be kept in step with the first.
76
+ */
77
+ function stripControlCharactersKeepingLineFeeds(text) {
78
+ return text.split("\n").map(stripControlCharacters).join("\n");
79
+ }
80
+ /**
81
+ * Matches the characters that take no width of their own.
82
+ *
83
+ * `Default_Ignorable_Code_Point` is the Unicode property for exactly this — the
84
+ * zero-width joiners, the soft hyphen, the variation selectors, the Hangul
85
+ * fillers, the tag characters — and the format category `Cf` covers the few
86
+ * that sit outside it, such as the interlinear annotation marks. Both are used
87
+ * rather than a list of ranges because a list has to be revisited every time
88
+ * Unicode adds one, and the one that is missed is the one an attacker reaches
89
+ * for: U+3164 HANGUL FILLER, the classic of the homograph domain names, is a
90
+ * letter of the Hangul script and would pass every check aimed at Latin.
91
+ *
92
+ * The braille blank is named on its own. It carries no dots, so it draws as
93
+ * nothing while belonging to neither set.
94
+ *
95
+ * None of these is a control character, so none is caught by
96
+ * `stripControlCharacters` — and none of them shows. A name that differs from
97
+ * another only by one of these is drawn exactly like it, which is why a name
98
+ * that carries one is not a name a user can be asked to judge.
99
+ */
100
+ const INVISIBLE_CHARACTERS_PATTERN = /[\p{Default_Ignorable_Code_Point}\p{Cf}\u2800]/gu;
101
+ /**
102
+ * Removes every zero-width and otherwise invisible character from `text`.
103
+ *
104
+ * Kept apart from `stripControlCharacters` because the two answer different
105
+ * questions. That one asks what is safe to print; this one asks whether a name
106
+ * shows everything it contains, which is what a prompt needs before it offers
107
+ * the name as something to pick.
108
+ */
109
+ function stripInvisibleCharacters(text) {
110
+ return text.replace(INVISIBLE_CHARACTERS_PATTERN, "");
111
+ }
112
+ /**
113
+ * Removes every character that does not show: the control characters and the
114
+ * invisible ones alike.
115
+ *
116
+ * This is the form a name has to survive unchanged before it can be offered as
117
+ * something to choose. Callers that need the whole answer should reach for this
118
+ * rather than composing the two strippers themselves, so that the order — and
119
+ * the definition of "hidden" — lives in one place.
120
+ */
121
+ function stripHiddenCharacters(text) {
122
+ return stripInvisibleCharacters(stripControlCharacters(text));
123
+ }
124
+ /**
125
+ * The invisible characters that do a job in some scripts rather than only
126
+ * hiding: the two zero-width joiners and the variation selectors.
127
+ *
128
+ * A Persian or Indic name spells a word with ZWNJ (U+200C) in it, and an emoji
129
+ * name is a chain of ZWJ (U+200D) and variation selectors. Refusing those
130
+ * outright would refuse names that are written the only way their script writes
131
+ * them, so they are judged by where they sit rather than by what they are.
132
+ */
133
+ const ZERO_WIDTH_JOINER_PATTERN = /\u200c|\u200d/u;
134
+ const VARIATION_SELECTOR_PATTERN = /[\u{fe00}-\u{fe0f}]|[\u{e0100}-\u{e01ef}]/u;
135
+ /** The twelve characters a keycap can be built on, per ED-14 of UTS #51. */
136
+ const KEYCAP_BASE_PATTERN = /[0-9#*]/u;
137
+ /** The selector that asks for the emoji form of the character before it. */
138
+ const EMOJI_PRESENTATION_SELECTOR$1 = "️";
139
+ /** U+20E3, which draws the box the base and the selector go inside. */
140
+ const COMBINING_ENCLOSING_KEYCAP = "⃣";
141
+ /**
142
+ * Whether the three characters are an emoji keycap, spelled as UTS #51 spells
143
+ * it: `Emoji_Keycap_Sequence := [0-9#*] FE0F 20E3`.
144
+ *
145
+ * The twelve bases are the only ASCII characters Unicode gives the `Emoji`
146
+ * property to, and not one of them is a pictograph — `1` is a digit and `#` is
147
+ * punctuation — so the joining list below cannot see the sequence, and a
148
+ * directory named `1\u{fe0f}\u{20e3}` would be turned away as a digit padded
149
+ * with a variation selector. It is not padding: the selector is what asks for
150
+ * the emoji form of the digit, and the enclosing keycap behind it is what draws
151
+ * the box around it. Both neighbors are required, which is what keeps the
152
+ * exception to the sequence rather than handing it to every digit in every
153
+ * name: `pdf1` with a variation selector and no keycap after it is padding
154
+ * still, and is refused still.
155
+ *
156
+ * @see https://www.unicode.org/reports/tr51/#def_emoji_keycap_sequence
157
+ */
158
+ function isKeycapSequence(params) {
159
+ const { base, selector, following } = params;
160
+ return base !== void 0 && KEYCAP_BASE_PATTERN.test(base) && selector === EMOJI_PRESENTATION_SELECTOR$1 && following === COMBINING_ENCLOSING_KEYCAP;
161
+ }
162
+ /**
163
+ * The characters a joiner has work to do beside: the scripts whose words are
164
+ * written with one, and the pictographs an emoji sequence is built from.
165
+ *
166
+ * A list of what may join rather than of what may not, because the two are not
167
+ * the same size. `pdf` with a ZWNJ between the d and the f is `pdf` on screen
168
+ * and a different directory underneath, and the same is true of `設定` with a
169
+ * ZWJ after the first character: neither Latin nor Han joins anything that way,
170
+ * and nor does Cyrillic, Greek, Hangul or kana. Naming the scripts that do —
171
+ * the Arabic family, the Indic ones, Mongolian, and the pictographs — is what
172
+ * keeps the exception to the names that need it, instead of handing it to every
173
+ * writing system that is merely not Latin.
174
+ */
175
+ const JOINING_CONTEXT_PATTERN = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Mongolian}\p{Script=Devanagari}\p{Script=Bengali}\p{Script=Gurmukhi}\p{Script=Gujarati}\p{Script=Oriya}\p{Script=Tamil}\p{Script=Telugu}\p{Script=Kannada}\p{Script=Malayalam}\p{Script=Sinhala}\p{Script=Myanmar}\p{Script=Khmer}\p{Script=Tibetan}\p{Script=Adlam}\p{Extended_Pictographic}]/u;
176
+ /** Non-global copies, because `test` on a global regex carries state between calls. */
177
+ const CONTROL_CHARACTER_PATTERN = new RegExp(CONTROL_CHARACTERS_PATTERN.source, "u");
178
+ const INVISIBLE_CHARACTER_PATTERN = new RegExp(INVISIBLE_CHARACTERS_PATTERN.source, "u");
179
+ /**
180
+ * Whether `text` carries a hidden character that is there to hide something.
181
+ *
182
+ * This is the question a name has to answer before it can be offered as
183
+ * something to pick, and it is a narrower one than `stripHiddenCharacters`
184
+ * answers. Every control character counts, and so does every invisible
185
+ * character — except a joiner or variation selector standing where its own
186
+ * script would put one, which is to say beside a character from a script that
187
+ * is written with joiners, or beside a pictograph.
188
+ *
189
+ * A joiner is held to both of its neighbors, since it exists to bind two
190
+ * characters and a name that ends in one is binding nothing: `設定` with a ZWJ
191
+ * after it is `設定` on screen and a second directory underneath. A variation
192
+ * selector is held only to the character before it, which is the one it selects
193
+ * a form for, and which is why an emoji name may end in one.
194
+ *
195
+ * The keycap sequence is the one emoji the joining list cannot recognize on its
196
+ * own, since what it is built on is a digit or an ASCII sign rather than a
197
+ * pictograph, so it is matched whole instead.
198
+ *
199
+ * Han is not on the joining list, so an ideographic variation sequence — a Han
200
+ * character followed by one of U+E0100 onward — is refused along with the rest.
201
+ * That is the intended trade: no skill directory here is named with one, and
202
+ * the pair is drawn as the bare character on every terminal that has no font
203
+ * for the variant, which is the shape the check exists to refuse.
204
+ *
205
+ * The test is a heuristic in place of the CONTEXTJ joining rules of IDNA,
206
+ * which decide the same question by the joining type of the characters around
207
+ * the joiner. It errs toward accepting a name written in a script that needs
208
+ * these characters, and toward rejecting one that mixes them into Latin, where
209
+ * they can only be padding.
210
+ */
211
+ function hasDeceptiveHiddenCharacters(text) {
212
+ const characters = [...text];
213
+ const joinsCharacter = (neighbor) => neighbor !== void 0 && JOINING_CONTEXT_PATTERN.test(neighbor);
214
+ return characters.some((character, index) => {
215
+ if (CONTROL_CHARACTER_PATTERN.test(character)) return true;
216
+ if (!INVISIBLE_CHARACTER_PATTERN.test(character)) return false;
217
+ if (VARIATION_SELECTOR_PATTERN.test(character)) {
218
+ if (isKeycapSequence({
219
+ base: characters[index - 1],
220
+ selector: character,
221
+ following: characters[index + 1]
222
+ })) return false;
223
+ return !joinsCharacter(characters[index - 1]);
224
+ }
225
+ if (!ZERO_WIDTH_JOINER_PATTERN.test(character)) return true;
226
+ return !joinsCharacter(characters[index - 1]) || !joinsCharacter(characters[index + 1]);
227
+ });
228
+ }
229
+ //#endregion
230
+ //#region src/utils/truncate.ts
231
+ /**
232
+ * Cut `text` to `maxLength` without splitting a character in half.
233
+ *
234
+ * `String.prototype.slice` counts UTF-16 units, so cutting inside a surrogate
235
+ * pair leaves a lone surrogate that the next encoder turns into a replacement
236
+ * character, and cutting inside a `\uXXXX` escape that `JSON.stringify` wrote
237
+ * leaves a dangling backslash. Diagnostics quote files rulesync did not write,
238
+ * so both are reachable from a repository's own content rather than only from
239
+ * a hand-crafted string.
240
+ */
241
+ function truncateText({ text, maxLength, suffix }) {
242
+ if (text.length <= maxLength) return text;
243
+ const characters = Array.from(text.slice(0, maxLength * 2 + 2));
244
+ if (characters.length <= maxLength) return text;
245
+ const cut = characters.slice(0, maxLength).join("");
246
+ return `${(/\\*$/.exec(cut)?.[0].length ?? 0) % 2 === 0 ? cut : cut.slice(0, -1)}${suffix}`;
247
+ }
248
+ //#endregion
43
249
  //#region src/utils/error.ts
44
250
  /**
45
251
  * Convert various error types to a readable error message
@@ -64,10 +270,52 @@ let _toon_format_toon = require("@toon-format/toon");
64
270
  function isZodErrorLike(error) {
65
271
  return error !== null && typeof error === "object" && "issues" in error && Array.isArray(error.issues) && error.issues.every((issue) => issue !== null && typeof issue === "object" && "path" in issue && Array.isArray(issue.path) && "message" in issue && typeof issue.message === "string");
66
272
  }
273
+ /**
274
+ * How much of a Zod error the formatted message spells out.
275
+ *
276
+ * One `safeParse` of a large invalid document produces an issue per offending
277
+ * node, each carrying the path and message, so the raw expansion is bounded by
278
+ * the size of the input rather than by anything rulesync decides — and the
279
+ * formatted message no longer stops at a terminal: it becomes the `message` of
280
+ * a `--json` failure document and of an MCP result. The first few issues are
281
+ * what tells the reader which file to open; the rest is the same information
282
+ * again, at whatever length the input chose.
283
+ */
284
+ const MAX_ZOD_ISSUES_LENGTH = 2e3;
285
+ /**
286
+ * How much of any other error the formatted message spells out.
287
+ *
288
+ * Larger than the Zod bound because the text is the error's own sentence rather
289
+ * than a re-listing of one issue per offending node, and because the MCP
290
+ * `generate` failure hands this one a line per unreadable source. Bounded all
291
+ * the same: a parser quotes the offending line verbatim, and a minified file is
292
+ * one line the length of the file.
293
+ */
294
+ const MAX_ERROR_MESSAGE_LENGTH = 8e3;
295
+ /**
296
+ * Strip and bound an error message that is about to be read by something other
297
+ * than a terminal — a `--json` failure document, an MCP result.
298
+ *
299
+ * The line feed stays: several messages are deliberately written over more than
300
+ * one line, and running them together would cost more than the newline can do
301
+ * here. Everything that reorders the text around it, or that an escape sequence
302
+ * is written with, goes.
303
+ */
304
+ function boundErrorMessage(text) {
305
+ return truncateText({
306
+ text: stripControlCharactersKeepingLineFeeds(text),
307
+ maxLength: MAX_ERROR_MESSAGE_LENGTH,
308
+ suffix: "…(truncated)"
309
+ });
310
+ }
67
311
  function formatError(error) {
68
- if (error instanceof zod.ZodError || isZodErrorLike(error)) return `Zod raw error: ${JSON.stringify(error.issues)}`;
69
- if (error instanceof Error) return `${error.name}: ${error.message}`;
70
- return String(error);
312
+ if (error instanceof zod.ZodError || isZodErrorLike(error)) return `Zod raw error: ${truncateText({
313
+ text: stripControlCharacters(JSON.stringify(error.issues)),
314
+ maxLength: MAX_ZOD_ISSUES_LENGTH,
315
+ suffix: "…(truncated)"
316
+ })}`;
317
+ if (error instanceof Error) return boundErrorMessage(`${error.name}: ${error.message}`);
318
+ return boundErrorMessage(String(error));
71
319
  }
72
320
  //#endregion
73
321
  //#region src/types/features.ts
@@ -147,34 +395,34 @@ const isFeatureValueEnabled = (value) => {
147
395
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
148
396
  //#endregion
149
397
  //#region src/constants/rulesync-paths.ts
150
- const { join: join$299 } = node_path.posix;
398
+ const { join: join$298 } = node_path.posix;
151
399
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
152
400
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
153
401
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
154
402
  const RULES_FEATURE_SUBDIR = "rules";
155
- const CURATED_RULES_FEATURE_SUBDIR = join$299(RULES_FEATURE_SUBDIR, ".curated");
403
+ const CURATED_RULES_FEATURE_SUBDIR = join$298(RULES_FEATURE_SUBDIR, ".curated");
156
404
  const COMMANDS_FEATURE_SUBDIR = "commands";
157
405
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
158
406
  const CHECKS_FEATURE_SUBDIR = "checks";
159
407
  const SKILLS_FEATURE_SUBDIR = "skills";
160
- const CURATED_SKILLS_FEATURE_SUBDIR = join$299(SKILLS_FEATURE_SUBDIR, ".curated");
161
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
162
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
163
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
164
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
165
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
166
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
167
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
168
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
169
- join$299(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
170
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
171
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
408
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$298(SKILLS_FEATURE_SUBDIR, ".curated");
409
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
410
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
411
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
412
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
413
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
414
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
415
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
416
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
417
+ join$298(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
418
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
419
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
172
420
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
173
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
421
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
174
422
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
175
423
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
176
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
177
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
424
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
425
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$298(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
178
426
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
179
427
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
180
428
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -361,7 +609,8 @@ const subagentsProcessorToolTargetTuple = [
361
609
  "zoocode",
362
610
  "rovodev",
363
611
  "takt",
364
- "vibe"
612
+ "vibe",
613
+ "zcode"
365
614
  ];
366
615
  const skillsProcessorToolTargetTuple = [
367
616
  "agentsmd",
@@ -541,138 +790,6 @@ async function mapWithConcurrency({ items, limit, mapper }) {
541
790
  return results;
542
791
  }
543
792
  //#endregion
544
- //#region src/utils/control-characters.ts
545
- /**
546
- * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
547
- * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
548
- * line and paragraph separators, and the plain LRM/RLM/ALM marks. A name or value
549
- * copied out of an untrusted config file, a fetched repository, or a tool's own
550
- * settings file must never reach the terminal with these intact: they let the
551
- * text forge log lines, reorder what is printed around them, or inject escape
552
- * sequences. LRM, RLM and the Arabic letter mark open no bidi scope of their
553
- * own, but they still reorder the neutral characters beside them, so they go too — a diagnostic line is not the
554
- * place to preserve the typography of a right-to-left name.
555
- */
556
- const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
557
- /**
558
- * Removes every control character from `text` so it is safe to splice into a
559
- * log line or other terminal output.
560
- */
561
- function stripControlCharacters(text) {
562
- return text.replace(CONTROL_CHARACTERS_PATTERN, "");
563
- }
564
- /**
565
- * Matches the characters that take no width of their own.
566
- *
567
- * `Default_Ignorable_Code_Point` is the Unicode property for exactly this — the
568
- * zero-width joiners, the soft hyphen, the variation selectors, the Hangul
569
- * fillers, the tag characters — and the format category `Cf` covers the few
570
- * that sit outside it, such as the interlinear annotation marks. Both are used
571
- * rather than a list of ranges because a list has to be revisited every time
572
- * Unicode adds one, and the one that is missed is the one an attacker reaches
573
- * for: U+3164 HANGUL FILLER, the classic of the homograph domain names, is a
574
- * letter of the Hangul script and would pass every check aimed at Latin.
575
- *
576
- * The braille blank is named on its own. It carries no dots, so it draws as
577
- * nothing while belonging to neither set.
578
- *
579
- * None of these is a control character, so none is caught by
580
- * `stripControlCharacters` — and none of them shows. A name that differs from
581
- * another only by one of these is drawn exactly like it, which is why a name
582
- * that carries one is not a name a user can be asked to judge.
583
- */
584
- const INVISIBLE_CHARACTERS_PATTERN = /[\p{Default_Ignorable_Code_Point}\p{Cf}\u2800]/gu;
585
- /**
586
- * Removes every zero-width and otherwise invisible character from `text`.
587
- *
588
- * Kept apart from `stripControlCharacters` because the two answer different
589
- * questions. That one asks what is safe to print; this one asks whether a name
590
- * shows everything it contains, which is what a prompt needs before it offers
591
- * the name as something to pick.
592
- */
593
- function stripInvisibleCharacters(text) {
594
- return text.replace(INVISIBLE_CHARACTERS_PATTERN, "");
595
- }
596
- /**
597
- * Removes every character that does not show: the control characters and the
598
- * invisible ones alike.
599
- *
600
- * This is the form a name has to survive unchanged before it can be offered as
601
- * something to choose. Callers that need the whole answer should reach for this
602
- * rather than composing the two strippers themselves, so that the order — and
603
- * the definition of "hidden" — lives in one place.
604
- */
605
- function stripHiddenCharacters(text) {
606
- return stripInvisibleCharacters(stripControlCharacters(text));
607
- }
608
- /**
609
- * The invisible characters that do a job in some scripts rather than only
610
- * hiding: the two zero-width joiners and the variation selectors.
611
- *
612
- * A Persian or Indic name spells a word with ZWNJ (U+200C) in it, and an emoji
613
- * name is a chain of ZWJ (U+200D) and variation selectors. Refusing those
614
- * outright would refuse names that are written the only way their script writes
615
- * them, so they are judged by where they sit rather than by what they are.
616
- */
617
- const ZERO_WIDTH_JOINER_PATTERN = /\u200c|\u200d/u;
618
- const VARIATION_SELECTOR_PATTERN = /[\u{fe00}-\u{fe0f}]|[\u{e0100}-\u{e01ef}]/u;
619
- /**
620
- * The characters a joiner has work to do beside: the scripts whose words are
621
- * written with one, and the pictographs an emoji sequence is built from.
622
- *
623
- * A list of what may join rather than of what may not, because the two are not
624
- * the same size. `pdf` with a ZWNJ between the d and the f is `pdf` on screen
625
- * and a different directory underneath, and the same is true of `設定` with a
626
- * ZWJ after the first character: neither Latin nor Han joins anything that way,
627
- * and nor does Cyrillic, Greek, Hangul or kana. Naming the scripts that do —
628
- * the Arabic family, the Indic ones, Mongolian, and the pictographs — is what
629
- * keeps the exception to the names that need it, instead of handing it to every
630
- * writing system that is merely not Latin.
631
- */
632
- const JOINING_CONTEXT_PATTERN = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Mongolian}\p{Script=Devanagari}\p{Script=Bengali}\p{Script=Gurmukhi}\p{Script=Gujarati}\p{Script=Oriya}\p{Script=Tamil}\p{Script=Telugu}\p{Script=Kannada}\p{Script=Malayalam}\p{Script=Sinhala}\p{Script=Myanmar}\p{Script=Khmer}\p{Script=Tibetan}\p{Script=Adlam}\p{Extended_Pictographic}]/u;
633
- /** Non-global copies, because `test` on a global regex carries state between calls. */
634
- const CONTROL_CHARACTER_PATTERN = new RegExp(CONTROL_CHARACTERS_PATTERN.source, "u");
635
- const INVISIBLE_CHARACTER_PATTERN = new RegExp(INVISIBLE_CHARACTERS_PATTERN.source, "u");
636
- /**
637
- * Whether `text` carries a hidden character that is there to hide something.
638
- *
639
- * This is the question a name has to answer before it can be offered as
640
- * something to pick, and it is a narrower one than `stripHiddenCharacters`
641
- * answers. Every control character counts, and so does every invisible
642
- * character — except a joiner or variation selector standing where its own
643
- * script would put one, which is to say beside a character from a script that
644
- * is written with joiners, or beside a pictograph.
645
- *
646
- * A joiner is held to both of its neighbors, since it exists to bind two
647
- * characters and a name that ends in one is binding nothing: `設定` with a ZWJ
648
- * after it is `設定` on screen and a second directory underneath. A variation
649
- * selector is held only to the character before it, which is the one it selects
650
- * a form for, and which is why an emoji name may end in one.
651
- *
652
- * Han is not on the joining list, so an ideographic variation sequence — a Han
653
- * character followed by one of U+E0100 onward — is refused along with the rest.
654
- * That is the intended trade: no skill directory here is named with one, and
655
- * the pair is drawn as the bare character on every terminal that has no font
656
- * for the variant, which is the shape the check exists to refuse.
657
- *
658
- * The test is a heuristic in place of the CONTEXTJ joining rules of IDNA,
659
- * which decide the same question by the joining type of the characters around
660
- * the joiner. It errs toward accepting a name written in a script that needs
661
- * these characters, and toward rejecting one that mixes them into Latin, where
662
- * they can only be padding.
663
- */
664
- function hasDeceptiveHiddenCharacters(text) {
665
- const characters = [...text];
666
- const joinsCharacter = (neighbor) => neighbor !== void 0 && JOINING_CONTEXT_PATTERN.test(neighbor);
667
- return characters.some((character, index) => {
668
- if (CONTROL_CHARACTER_PATTERN.test(character)) return true;
669
- if (!INVISIBLE_CHARACTER_PATTERN.test(character)) return false;
670
- if (VARIATION_SELECTOR_PATTERN.test(character)) return !joinsCharacter(characters[index - 1]);
671
- if (!ZERO_WIDTH_JOINER_PATTERN.test(character)) return true;
672
- return !joinsCharacter(characters[index - 1]) || !joinsCharacter(characters[index + 1]);
673
- });
674
- }
675
- //#endregion
676
793
  //#region src/utils/vitest.ts
677
794
  function isEnvTest() {
678
795
  return process.env.NODE_ENV === "test";
@@ -1029,7 +1146,16 @@ async function isSymlink(filepath) {
1029
1146
  return false;
1030
1147
  }
1031
1148
  }
1032
- async function listDirectoryFiles(dir) {
1149
+ /**
1150
+ * Every entry name directly under `dir`, of whatever kind, in the order the
1151
+ * filesystem reports them.
1152
+ *
1153
+ * Named for what it returns, because {@link listFileNames} sits beside it and
1154
+ * answers a narrower question: this one reports directories and links as well
1155
+ * as files, and reads an unreadable directory as an empty one instead of
1156
+ * failing. Picking this where the other was meant brings both of those back.
1157
+ */
1158
+ async function listDirectoryEntryNames(dir) {
1033
1159
  try {
1034
1160
  return await (0, node_fs_promises.readdir)(dir);
1035
1161
  } catch {
@@ -1041,6 +1167,20 @@ function countHiddenSegments(filePath) {
1041
1167
  return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
1042
1168
  }
1043
1169
  /**
1170
+ * A path the running platform produced, written posix-separated.
1171
+ *
1172
+ * Not {@link toPosixPath}, which rewrites every backslash whatever it means. That
1173
+ * is right for a path a caller spelled, which may be spelled either way; it is
1174
+ * wrong for one the platform handed back. On a posix platform a backslash is an
1175
+ * ordinary character in a name, so rewriting it folds `a\b` onto `a/b` -- two
1176
+ * different directories, and wherever the result is used as a file's identity the
1177
+ * second one to be seen is taken for a repeat of the first and dropped. On
1178
+ * Windows no name can hold a backslash, so the rewrite there is lossless.
1179
+ */
1180
+ function nativePathToPosix(filePath) {
1181
+ return node_path.sep === "\\" ? filePath.replaceAll("\\", "/") : filePath;
1182
+ }
1183
+ /**
1044
1184
  * The real file a path denotes, posix-separated so it compares against the globby results
1045
1185
  * that produce it. Two paths share an identity when they resolve to the very same file --
1046
1186
  * a link beside its target, a link into a shared tree, or a cycle that walks back into an
@@ -1048,19 +1188,61 @@ function countHiddenSegments(filePath) {
1048
1188
  */
1049
1189
  async function realFileIdentity(filePath) {
1050
1190
  try {
1051
- return toPosixPath(await (0, node_fs_promises.realpath)(filePath));
1191
+ return nativePathToPosix(await (0, node_fs_promises.realpath)(filePath));
1052
1192
  } catch {
1053
- return toPosixPath(filePath);
1193
+ return nativePathToPosix(filePath);
1054
1194
  }
1055
1195
  }
1056
1196
  /**
1197
+ * Where the file `targetPath` really denotes sits relative to the one `rootPath`
1198
+ * does, with every link on both sides resolved. Posix-separated, because the
1199
+ * resolved paths it is built from are; a caller that splits it must split on `/`
1200
+ * alone, which is also the only separator a real name can never contain.
1201
+ *
1202
+ * Both sides fall back to their literal path when they cannot be resolved, so an
1203
+ * unresolvable path reads as an escape rather than as a contained one.
1204
+ */
1205
+ async function resolvedRelativePath({ rootPath, targetPath }) {
1206
+ const [realRootPath, realTargetPath] = await Promise.all([realFileIdentity(rootPath), realFileIdentity(targetPath)]);
1207
+ return node_path.posix.relative(realRootPath, realTargetPath);
1208
+ }
1209
+ /**
1210
+ * Whether a posix relative path -- one {@link resolvedRelativePath} returned, or any
1211
+ * built the same way -- leads out of the root it was taken against.
1212
+ *
1213
+ * Exported so a caller that already holds the relative path can ask this of the very
1214
+ * path it goes on to read, instead of resolving both sides a second time and judging
1215
+ * a result it then has to trust is the same one.
1216
+ */
1217
+ function posixRelativePathEscapesRoot(relativePath) {
1218
+ return relativePath === ".." || relativePath.startsWith("../") || node_path.posix.isAbsolute(relativePath);
1219
+ }
1220
+ /**
1221
+ * How many trailing segments `filePath` and `identity` have in common.
1222
+ *
1223
+ * A path that walked through no link at all shares all of its own segments with
1224
+ * the file's identity; one that walked through a link named differently from
1225
+ * its target parts from the identity at that segment and shares only what
1226
+ * follows it. Counting from the end rather than testing the two for equality is
1227
+ * what lets the comparison hold for a path that is not itself resolved: a glob
1228
+ * rooted at a directory that is a link of its own gives every candidate the
1229
+ * same unresolved prefix, and only the segments below it decide.
1230
+ */
1231
+ function sharedTrailingSegments(filePath, identity) {
1232
+ const left = splitPathSegments(nativePathToPosix(filePath));
1233
+ const right = splitPathSegments(identity);
1234
+ let shared = 0;
1235
+ while (shared < left.length && shared < right.length && left[left.length - 1 - shared] === right[right.length - 1 - shared]) shared++;
1236
+ return shared;
1237
+ }
1238
+ /**
1057
1239
  * Pick the one path that represents a file among the paths that resolve to it.
1058
1240
  *
1059
- * The path that walked through no link at all wins outright: it is already the real one,
1060
- * so it equals the file's identity. That keeps the real location of a file as the path
1061
- * callers see, rather than an alias that happens to sort first -- a directory link named
1062
- * `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a cycle must not replace
1063
- * `sub/note.md` with the same file reached back through the cycle.
1241
+ * The path that walked through no link at all wins outright: it shares every one of its
1242
+ * segments with the file's identity, which no alias does. That keeps the real location of
1243
+ * a file as the path callers see, rather than an alias that happens to sort first -- a
1244
+ * directory link named `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a
1245
+ * cycle must not replace `sub/note.md` with the same file reached back through the cycle.
1064
1246
  * Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
1065
1247
  * named one represents the entry rather than a hidden alias that a hidden-entry rule may
1066
1248
  * then drop, taking the named path's content with it. `candidates` arrives in sorted
@@ -1068,8 +1250,9 @@ async function realFileIdentity(filePath) {
1068
1250
  */
1069
1251
  function chooseRepresentative(candidates, identity) {
1070
1252
  return candidates.reduce((best, candidate) => {
1071
- if (toPosixPath(best) === identity) return best;
1072
- if (toPosixPath(candidate) === identity) return candidate;
1253
+ const bestShared = sharedTrailingSegments(best, identity);
1254
+ const candidateShared = sharedTrailingSegments(candidate, identity);
1255
+ if (candidateShared !== bestShared) return candidateShared > bestShared ? candidate : best;
1073
1256
  return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
1074
1257
  });
1075
1258
  }
@@ -1167,7 +1350,7 @@ async function dedupeNamesByFileIdentity(params) {
1167
1350
  });
1168
1351
  const entriesByIdentity = /* @__PURE__ */ new Map();
1169
1352
  for (const [index, entry] of entries.entries()) {
1170
- const identity = identities[index] ?? toPosixPath((0, node_path.join)(dirPath, entry.name));
1353
+ const identity = identities[index] ?? nativePathToPosix((0, node_path.join)(dirPath, entry.name));
1171
1354
  const group = entriesByIdentity.get(identity);
1172
1355
  if (group === void 0) entriesByIdentity.set(identity, [entry]);
1173
1356
  else group.push(entry);
@@ -1219,6 +1402,94 @@ async function listFileNames(dirPath, options = {}) {
1219
1402
  nameFilter: options.nameFilter
1220
1403
  });
1221
1404
  }
1405
+ /**
1406
+ * The paths of every file below `dirPath`, relative to it, walked rather than
1407
+ * globbed.
1408
+ *
1409
+ * The recursive counterpart of {@link listFileNames}, and there for the same
1410
+ * reason: globby reads a backslash as a path separator and rewrites it in the
1411
+ * paths it returns, so a file named `back\\slash.md` comes back as
1412
+ * `back/slash.md` — a path that belongs to no file, under a name that belongs
1413
+ * to no file either.
1414
+ *
1415
+ * A root that is not there is an empty root. A root that is there but cannot be
1416
+ * read is reported, so it is never mistaken for an empty one — as is a
1417
+ * directory below it, since a subtree silently missing from the result is the
1418
+ * same mistake one level down.
1419
+ *
1420
+ * A directory link is followed like any other directory, but each real
1421
+ * directory is walked only once, so neither a cycle nor a mesh of links can
1422
+ * make the walk repeat itself -- taking every distinct route through a graph of
1423
+ * aliases would cost one traversal per route, which a handful of links is
1424
+ * enough to make hopeless. The name a twice-reachable directory is reported
1425
+ * under is therefore whichever the walk reaches first, and the walk goes level
1426
+ * by level with each level sorted, so that is the shortest path to it and the
1427
+ * first in sorted order among equals. Note the difference from
1428
+ * {@link findFilesByGlobs}, which resolves each of its results and keeps the
1429
+ * real one: here a link nearer the root than the directory it points at stands
1430
+ * in for it. `nameFilter` narrows the files, not the directories the walk
1431
+ * descends into.
1432
+ *
1433
+ * The cost is one round of `readdir` per directory, taken in sequence, and the
1434
+ * whole of a level is held at once, so residency follows the widest level
1435
+ * rather than the deepest path.
1436
+ */
1437
+ async function listFilePathsRecursively(dirPath, options = {}) {
1438
+ const { followSymbolicLinks = true, includeHidden = false, nameFilter, deduplicateByFileIdentity = false } = options;
1439
+ if (!await directoryExists(dirPath)) return [];
1440
+ const filePaths = [];
1441
+ const walkedIdentities = /* @__PURE__ */ new Set();
1442
+ let level = [{
1443
+ currentPath: dirPath,
1444
+ prefix: ""
1445
+ }];
1446
+ while (level.length > 0) {
1447
+ const nextLevel = [];
1448
+ for (const { currentPath, prefix } of level.toSorted((a, b) => a.prefix < b.prefix ? -1 : a.prefix > b.prefix ? 1 : 0)) {
1449
+ const identity = await realFileIdentity(currentPath);
1450
+ if (walkedIdentities.has(identity)) continue;
1451
+ walkedIdentities.add(identity);
1452
+ const [fileNames, dirNames] = await Promise.all([listFileNames(currentPath, {
1453
+ followSymbolicLinks,
1454
+ includeHidden,
1455
+ nameFilter
1456
+ }), listSubdirectoryNames(currentPath, {
1457
+ followSymbolicLinks,
1458
+ includeHidden
1459
+ })]);
1460
+ for (const fileName of fileNames) filePaths.push(prefix === "" ? fileName : (0, node_path.join)(prefix, fileName));
1461
+ for (const dirName of dirNames) nextLevel.push({
1462
+ currentPath: (0, node_path.join)(currentPath, dirName),
1463
+ prefix: prefix === "" ? dirName : (0, node_path.join)(prefix, dirName)
1464
+ });
1465
+ }
1466
+ level = nextLevel;
1467
+ }
1468
+ return deduplicateByFileIdentity ? await deduplicateRelativePathsByFileIdentity({
1469
+ dirPath,
1470
+ relativePaths: filePaths
1471
+ }) : filePaths.toSorted();
1472
+ }
1473
+ /**
1474
+ * One path per real file, chosen the way {@link findFilesByGlobs} chooses it.
1475
+ *
1476
+ * The walk de-duplicates the directories it descends into, so it cannot loop,
1477
+ * but it still reports every name it walks past: a file reached under two names
1478
+ * is listed twice. A caller reading the result as a set of files has to fold
1479
+ * those aliases together, and has to fold them the same way the glob does, or
1480
+ * the two disagree about the name a file has.
1481
+ */
1482
+ async function deduplicateRelativePathsByFileIdentity({ dirPath, relativePaths }) {
1483
+ const candidatesByFile = /* @__PURE__ */ new Map();
1484
+ for (const relativePath of relativePaths.toSorted()) {
1485
+ const absolutePath = (0, node_path.join)(dirPath, relativePath);
1486
+ const identity = await realFileIdentity(absolutePath);
1487
+ const candidates = candidatesByFile.get(identity);
1488
+ if (candidates === void 0) candidatesByFile.set(identity, [absolutePath]);
1489
+ else candidates.push(absolutePath);
1490
+ }
1491
+ return [...candidatesByFile.entries()].map(([identity, candidates]) => (0, node_path.relative)(dirPath, chooseRepresentative(candidates, identity))).toSorted();
1492
+ }
1222
1493
  async function removeDirectory(dirPath) {
1223
1494
  if ([
1224
1495
  ".",
@@ -1380,24 +1651,111 @@ var CLIError = class extends Error {
1380
1651
  //#region src/utils/warned-once.ts
1381
1652
  /**
1382
1653
  * The messages a once-per-run warning has already emitted in this process.
1383
- * This lives in its own module, free of imports, so the vitest setup file can
1384
- * clear it between tests without pulling `logger.js` into every test's module
1385
- * graph (which would defeat the module mocks some of those tests install).
1654
+ * This lives in its own module, importing nothing of rulesync's, so the vitest
1655
+ * setup file can clear it between tests without pulling `logger.js` into every
1656
+ * test's module graph (which would defeat the module mocks some of those tests
1657
+ * install).
1386
1658
  */
1387
- const warnedOnceMessages = /* @__PURE__ */ new Set();
1659
+ const processWideMessages = /* @__PURE__ */ new Set();
1660
+ /**
1661
+ * The set an operation that opened its own scope uses instead.
1662
+ *
1663
+ * The MCP server does not serialize requests, so two runs can be in flight at
1664
+ * once. Sharing one set between them would let the first run spend the token
1665
+ * for a message and leave the second one's result silent about a diagnostic
1666
+ * that applies to it just as much. A scope gives each run its own bookkeeping.
1667
+ */
1668
+ const scopedMessages = new node_async_hooks.AsyncLocalStorage();
1669
+ function currentMessages() {
1670
+ return scopedMessages.getStore() ?? processWideMessages;
1671
+ }
1388
1672
  /** Whether `message` has not been emitted yet; records it when it has not. */
1389
1673
  function claimWarnOnce(message) {
1390
- if (warnedOnceMessages.has(message)) return false;
1391
- warnedOnceMessages.add(message);
1674
+ const messages = currentMessages();
1675
+ if (messages.has(message)) return false;
1676
+ messages.add(message);
1392
1677
  return true;
1393
1678
  }
1394
- /** Forget which warnings were already emitted, so each test starts silent. */
1679
+ /** Forget which warnings were already emitted, so the next run starts silent. */
1395
1680
  function resetWarnedOnceMessages() {
1396
- warnedOnceMessages.clear();
1681
+ currentMessages().clear();
1682
+ }
1683
+ /**
1684
+ * Run `operation` with its own once-per-run bookkeeping, so a concurrent run
1685
+ * neither spends its tokens nor clears its record.
1686
+ */
1687
+ async function withWarnOnceScope(operation) {
1688
+ return await scopedMessages.run(/* @__PURE__ */ new Set(), operation);
1397
1689
  }
1398
1690
  //#endregion
1399
1691
  //#region src/utils/logger.ts
1400
1692
  /**
1693
+ * Formats a log line the way `console.warn` would, so a warning that is handed
1694
+ * back to a caller reads the same as the one that reaches a terminal.
1695
+ */
1696
+ function formatLogLine({ message, args }) {
1697
+ return args.length === 0 ? message : (0, node_util.format)(message, ...args);
1698
+ }
1699
+ /**
1700
+ * How much a collecting logger keeps: at most this many warnings, each at most
1701
+ * this long, and no more than this in total.
1702
+ *
1703
+ * Collected warnings travel to places a console line does not — a `--json`
1704
+ * document that another program parses, an MCP result that an agent reads as
1705
+ * context — and their text quotes files rulesync did not write. So the amount a
1706
+ * repository can push through has to be bounded twice over: a config with
1707
+ * thousands of odd keys is a plausible accident, and a report sized in hundreds
1708
+ * of kilobytes is a generous budget for text aimed at whoever reads it next.
1709
+ * The total is the binding limit; the per-line and per-count limits keep one
1710
+ * enormous warning, or one enormous number of them, from being the whole of it.
1711
+ */
1712
+ const MAX_COLLECTED_WARNINGS = 100;
1713
+ const MAX_COLLECTED_WARNING_LENGTH = 1e3;
1714
+ const MAX_COLLECTED_TOTAL_LENGTH = 8e3;
1715
+ /**
1716
+ * How many distinct warnings the de-duplication remembers.
1717
+ *
1718
+ * The record has to outlive the reported lines — a line dropped for want of
1719
+ * budget must not be counted again the next time the same diagnostic repeats —
1720
+ * so it grows with the number of distinct warnings a run raises rather than
1721
+ * with the number reported. Bounded for the same reason everything else here
1722
+ * is: past this many, later repeats are counted rather than recognized, which
1723
+ * inflates the trailing count but cannot grow the record without end.
1724
+ */
1725
+ const MAX_DEDUPLICATED_WARNINGS = 1e3;
1726
+ /**
1727
+ * A bounded list of warning lines.
1728
+ */
1729
+ var WarningCollection = class {
1730
+ lines = [];
1731
+ seen = /* @__PURE__ */ new Set();
1732
+ totalLength = 0;
1733
+ omitted = 0;
1734
+ add({ message, args }) {
1735
+ const kept = truncateText({
1736
+ text: stripControlCharacters(formatLogLine({
1737
+ message,
1738
+ args
1739
+ })),
1740
+ maxLength: MAX_COLLECTED_WARNING_LENGTH,
1741
+ suffix: "…(truncated)"
1742
+ });
1743
+ if (this.seen.has(kept)) return;
1744
+ if (this.lines.length >= MAX_COLLECTED_WARNINGS || this.totalLength + kept.length > MAX_COLLECTED_TOTAL_LENGTH) {
1745
+ if (this.seen.size < MAX_DEDUPLICATED_WARNINGS) this.seen.add(kept);
1746
+ this.omitted++;
1747
+ return;
1748
+ }
1749
+ this.seen.add(kept);
1750
+ this.lines.push(kept);
1751
+ this.totalLength += kept.length;
1752
+ }
1753
+ toArray() {
1754
+ if (this.omitted === 0) return [...this.lines];
1755
+ return [...this.lines, `… and ${this.omitted} more warning(s) not reported`];
1756
+ }
1757
+ };
1758
+ /**
1401
1759
  * Base class for shared verbose/silent state and configuration logic
1402
1760
  */
1403
1761
  var BaseLogger = class {
@@ -1413,6 +1771,9 @@ var BaseLogger = class {
1413
1771
  get silent() {
1414
1772
  return this._silent;
1415
1773
  }
1774
+ get reportsWhileSilent() {
1775
+ return false;
1776
+ }
1416
1777
  configure({ verbose, silent }) {
1417
1778
  this._silent = silent;
1418
1779
  this._verbose = verbose && !silent;
@@ -1458,11 +1819,18 @@ var ConsoleLogger = class extends BaseLogger {
1458
1819
  /**
1459
1820
  * JsonLogger - structured JSON output to stdout/stderr
1460
1821
  *
1461
- * All console output methods (info, success, warn, debug) are no-ops.
1822
+ * The console output methods (info, success, debug) are no-ops. `warn` is not:
1823
+ * a diagnostic that only reached the console would be invisible to a `--json`
1824
+ * consumer, which reads the document and nothing else, so warnings are
1825
+ * collected and emitted as the document's top-level `warnings` array instead.
1826
+ * Top-level rather than inside `data` so it can never collide with a key a
1827
+ * command captured, and so it survives on the failure document too — the case
1828
+ * where a diagnostic about the input is most likely to explain the failure.
1462
1829
  */
1463
1830
  var JsonLogger = class extends BaseLogger {
1464
1831
  _jsonOutputDone = false;
1465
1832
  _jsonData = {};
1833
+ _warnings = new WarningCollection();
1466
1834
  _commandName;
1467
1835
  _version;
1468
1836
  constructor({ command, version, verbose = false, silent = false }) {
@@ -1491,6 +1859,8 @@ var JsonLogger = class extends BaseLogger {
1491
1859
  command: this._commandName,
1492
1860
  version: this._version
1493
1861
  };
1862
+ const warnings = this._warnings.toArray();
1863
+ if (warnings.length > 0) output.warnings = warnings;
1494
1864
  if (success) output.data = this._jsonData;
1495
1865
  else if (error) {
1496
1866
  output.error = {
@@ -1506,7 +1876,13 @@ var JsonLogger = class extends BaseLogger {
1506
1876
  }
1507
1877
  info(_message, ..._args) {}
1508
1878
  success(_message, ..._args) {}
1509
- warn(_message, ..._args) {}
1879
+ warn(message, ...args) {
1880
+ if (this._silent) return;
1881
+ this._warnings.add({
1882
+ message,
1883
+ args
1884
+ });
1885
+ }
1510
1886
  error(message, code, ..._args) {
1511
1887
  if (isEnvTest()) return;
1512
1888
  const errorMessage = message instanceof Error ? message.message : message;
@@ -1532,13 +1908,110 @@ function warnOnConflictingFlags({ verbose, silent, jsonMode }) {
1532
1908
  console.warn("Both --verbose and --silent specified; --silent takes precedence");
1533
1909
  }
1534
1910
  /**
1911
+ * Where `fallbackLogger` sends what it is given, scoped to the operation that
1912
+ * adopted it.
1913
+ *
1914
+ * An `AsyncLocalStorage` rather than a plain variable because the target is
1915
+ * per-operation, not per-process: a long-lived MCP server can have two requests
1916
+ * in flight at once, and a save/restore pair would let the first one to finish
1917
+ * hand the still-running request's warnings back to a console nobody reads.
1918
+ * Each operation sees only its own store.
1919
+ */
1920
+ const fallbackTargetStorage = new node_async_hooks.AsyncLocalStorage();
1921
+ /**
1922
+ * Where warnings go outside any adopted scope: a plain console logger, which is
1923
+ * what a code path with no logger threaded through would otherwise have used.
1924
+ */
1925
+ const defaultFallbackTarget = new ConsoleLogger();
1926
+ function currentFallbackTarget() {
1927
+ return fallbackTargetStorage.getStore() ?? defaultFallbackTarget;
1928
+ }
1929
+ /**
1930
+ * True while the forwarder is inside a call it is forwarding.
1931
+ *
1932
+ * The adopted target is supposed to be something other than the forwarder, but
1933
+ * a wrapper *around* it — `hooks-processor.ts` returns one that prefixes the
1934
+ * tool target onto every warning — passes the identity check in
1935
+ * {@link withFallbackLoggerTarget} and would forward straight back here. A
1936
+ * plain module-level flag is enough because the forwarding is synchronous: the
1937
+ * call returns before anything else can run.
1938
+ */
1939
+ let forwarding = false;
1940
+ /**
1941
+ * Reads from, or writes to, whichever logger the running operation adopted,
1942
+ * with the wrapper case above cut off at one hop.
1943
+ *
1944
+ * Every member of the forwarder goes through here, not just the two that write:
1945
+ * `warnOnceWithFallback` reads `silent` and `reportsWhileSilent` before it ever
1946
+ * calls `warn`, so a guard on the writing side alone would still be reached
1947
+ * through a getter that never returns.
1948
+ */
1949
+ function throughFallbackTarget(use) {
1950
+ if (forwarding) return use(defaultFallbackTarget);
1951
+ forwarding = true;
1952
+ try {
1953
+ return use(currentFallbackTarget());
1954
+ } finally {
1955
+ forwarding = false;
1956
+ }
1957
+ }
1958
+ /**
1535
1959
  * Shared fallback logger for code paths that have no command logger threaded
1536
1960
  * through (module-level translators, `warnWithFallback(undefined, ...)`).
1537
- * `wrapCommand` configures it from CLI flags and `ConfigResolver.resolve`
1538
- * re-configures it from the resolved config, so `silent`/`verbose` settings
1539
- * are honored even on paths where the command logger is not available.
1961
+ *
1962
+ * It is a thin forwarder rather than a logger of its own so that the operation
1963
+ * currently running can adopt it: `wrapCommand` points it at the command
1964
+ * logger, which is how a warning raised deep in a translator still reaches a
1965
+ * `--json` document or an MCP result instead of being written to a console that
1966
+ * nobody in those modes is reading. Modules that captured a reference to
1967
+ * `fallbackLogger` at import time follow the redirection too, which a
1968
+ * swapped-out binding would not give us.
1969
+ */
1970
+ const fallbackLogger = {
1971
+ configure(options) {
1972
+ defaultFallbackTarget.configure(options);
1973
+ },
1974
+ get verbose() {
1975
+ return throughFallbackTarget((target) => target.verbose);
1976
+ },
1977
+ get silent() {
1978
+ return throughFallbackTarget((target) => target.silent);
1979
+ },
1980
+ get reportsWhileSilent() {
1981
+ return throughFallbackTarget((target) => target.reportsWhileSilent);
1982
+ },
1983
+ get jsonMode() {
1984
+ return throughFallbackTarget((target) => target.jsonMode);
1985
+ },
1986
+ captureData(_key, _value) {},
1987
+ getJsonData() {
1988
+ return {};
1989
+ },
1990
+ outputJson(_success, _error) {},
1991
+ info(_message, ..._args) {},
1992
+ success(_message, ..._args) {},
1993
+ warn(message, ...args) {
1994
+ throughFallbackTarget((target) => {
1995
+ target.warn(message, ...args);
1996
+ });
1997
+ },
1998
+ error(message, code, ...args) {
1999
+ defaultFallbackTarget.error(message, code, ...args);
2000
+ },
2001
+ debug(_message, ..._args) {}
2002
+ };
2003
+ /**
2004
+ * Run `operation` with `fallbackLogger` pointed at `logger`, so warnings raised
2005
+ * where no logger was threaded through end up in the same place as the rest of
2006
+ * that operation's diagnostics.
2007
+ *
2008
+ * The redirection lasts exactly as long as the operation and is invisible to
2009
+ * anything running beside it.
1540
2010
  */
1541
- const fallbackLogger = new ConsoleLogger();
2011
+ async function withFallbackLoggerTarget({ logger, operation }) {
2012
+ if (logger === fallbackLogger) return await operation();
2013
+ return await fallbackTargetStorage.run(logger, () => withWarnOnceScope(operation));
2014
+ }
1542
2015
  /**
1543
2016
  * Emit a warning through `logger.warn` if a logger is supplied, otherwise
1544
2017
  * fall through to the shared `fallbackLogger`. Centralizes the "logger may
@@ -1556,9 +2029,35 @@ function warnWithFallback(logger, message) {
1556
2029
  * varies with what the user should do next does not.
1557
2030
  */
1558
2031
  function warnOnceWithFallback(logger, message) {
2032
+ const destination = logger ?? fallbackLogger;
2033
+ if (destination.silent && !destination.reportsWhileSilent) return;
1559
2034
  if (!claimWarnOnce(message)) return;
1560
- warnWithFallback(logger, message);
2035
+ destination.warn(message);
1561
2036
  }
2037
+ /**
2038
+ * A `ConsoleLogger` that keeps the warnings it is given.
2039
+ *
2040
+ * A caller with no console to write to — an MCP tool answering over stdio, where
2041
+ * the server's stderr never reaches the agent — can hand this in and put what
2042
+ * was reported into its own result, so a diagnostic about the files it just read
2043
+ * is something the agent can act on rather than something it never hears.
2044
+ */
2045
+ var WarningCollectingLogger = class extends ConsoleLogger {
2046
+ warnings = new WarningCollection();
2047
+ get reportsWhileSilent() {
2048
+ return true;
2049
+ }
2050
+ warn(message, ...args) {
2051
+ this.warnings.add({
2052
+ message,
2053
+ args
2054
+ });
2055
+ super.warn(message, ...args);
2056
+ }
2057
+ getWarnings() {
2058
+ return this.warnings.toArray();
2059
+ }
2060
+ };
1562
2061
  //#endregion
1563
2062
  //#region src/utils/validation.ts
1564
2063
  /**
@@ -4565,6 +5064,17 @@ function parseStrict(content) {
4565
5064
  return result;
4566
5065
  }
4567
5066
  /**
5067
+ * The error a source document should fail with when
5068
+ * {@link parseJsoncReportingDroppedKeys} reports keys the parser removed.
5069
+ *
5070
+ * Shared by every source that reports them, so the three files a user can
5071
+ * author explain the same removal the same way rather than each inventing its
5072
+ * own wording.
5073
+ */
5074
+ function droppedPollutionKeysError({ sourcePath, droppedKeys }) {
5075
+ return /* @__PURE__ */ new Error(`${JSON.stringify(sourcePath)} uses ${droppedKeys.map((key) => JSON.stringify(key)).join(", ")} as ${droppedKeys.length === 1 ? "a key" : "keys"}. Rulesync removes __proto__, constructor and prototype from every source document it parses, because assigning them would reach the prototype chain instead of the object. They are therefore never written to any tool's config — rename them rather than leaving entries that silently do nothing.`);
5076
+ }
5077
+ /**
4568
5078
  * The same parse as {@link parseJsonc}, additionally reporting which
4569
5079
  * prototype-pollution keys were removed, as dotted paths
4570
5080
  * (`permission.bash.__proto__`).
@@ -4644,9 +5154,17 @@ async function resolveRulesyncSourceWritePath({ outputRoot, paths }) {
4644
5154
  //#region src/features/hooks/rulesync-hooks.ts
4645
5155
  var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
4646
5156
  json;
5157
+ /**
5158
+ * Prototype-pollution keys the parser removed. They are dropped before the
5159
+ * schema ever sees them, so without this record a hook keyed `constructor`
5160
+ * would produce neither an error nor an entry in any generated file.
5161
+ */
5162
+ droppedKeys;
4647
5163
  constructor(params) {
4648
5164
  super({ ...params });
4649
- this.json = parseJsonc$8(this.fileContent);
5165
+ const { value, droppedKeys } = parseJsoncReportingDroppedKeys({ content: this.fileContent });
5166
+ this.json = value;
5167
+ this.droppedKeys = droppedKeys;
4650
5168
  if (params.validate) {
4651
5169
  const result = this.validate();
4652
5170
  if (!result.success) throw result.error;
@@ -4665,6 +5183,13 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
4665
5183
  };
4666
5184
  }
4667
5185
  validate() {
5186
+ if (this.droppedKeys.length > 0) return {
5187
+ success: false,
5188
+ error: droppedPollutionKeysError({
5189
+ sourcePath: this.getRelativePathFromCwd(),
5190
+ droppedKeys: this.droppedKeys
5191
+ })
5192
+ };
4668
5193
  const result = HooksConfigSchema.safeParse(this.json);
4669
5194
  if (!result.success) return {
4670
5195
  success: false,
@@ -5011,9 +5536,17 @@ function mergeMcpJsonOverlays({ base, overlay }) {
5011
5536
  }
5012
5537
  var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
5013
5538
  json;
5539
+ /**
5540
+ * Prototype-pollution keys the parser removed. They are dropped before the
5541
+ * schema ever sees them, so without this record a server named `__proto__`
5542
+ * would produce neither an error nor an entry in any generated file.
5543
+ */
5544
+ droppedKeys;
5014
5545
  constructor(params) {
5015
5546
  super(params);
5016
- this.json = parseJsonc$8(this.fileContent);
5547
+ const { value, droppedKeys } = parseJsoncReportingDroppedKeys({ content: this.fileContent });
5548
+ this.json = value;
5549
+ this.droppedKeys = droppedKeys;
5017
5550
  if (params.validate) {
5018
5551
  const result = this.validate();
5019
5552
  if (!result.success) throw result.error;
@@ -5035,6 +5568,13 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
5035
5568
  };
5036
5569
  }
5037
5570
  validate() {
5571
+ if (this.droppedKeys.length > 0) return {
5572
+ success: false,
5573
+ error: droppedPollutionKeysError({
5574
+ sourcePath: this.getRelativePathFromCwd(),
5575
+ droppedKeys: this.droppedKeys
5576
+ })
5577
+ };
5038
5578
  const result = RulesyncMcpFileSchema.safeParse(this.json);
5039
5579
  if (!result.success) return {
5040
5580
  success: false,
@@ -5104,16 +5644,23 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
5104
5644
  }
5105
5645
  const fileContent = await readFileContent(filePath);
5106
5646
  let parsed;
5647
+ let droppedKeys;
5107
5648
  try {
5108
- parsed = parseJsonc$8(fileContent);
5109
- if (!isRecord$1(parsed)) throw new Error("Expected a JSON object.");
5110
- if (validate) {
5111
- const result = RulesyncMcpFileSchema.safeParse(parsed);
5112
- if (!result.success) throw result.error;
5113
- }
5649
+ const result = parseJsoncReportingDroppedKeys({ content: fileContent });
5650
+ if (!isRecord$1(result.value)) throw new Error("Expected a JSON object.");
5651
+ parsed = result.value;
5652
+ droppedKeys = result.droppedKeys;
5114
5653
  } catch (error) {
5115
5654
  throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
5116
5655
  }
5656
+ if (validate) {
5657
+ if (droppedKeys.length > 0) throw droppedPollutionKeysError({
5658
+ sourcePath: toPosixPath((0, node_path.relative)(process.cwd(), filePath)),
5659
+ droppedKeys
5660
+ });
5661
+ const result = RulesyncMcpFileSchema.safeParse(parsed);
5662
+ if (!result.success) throw new Error(`Invalid MCP source file '${filePath}': ${formatError(result.error)}`, { cause: result.error });
5663
+ }
5117
5664
  rootSources.push({
5118
5665
  record: parsed,
5119
5666
  outputRoot: parent,
@@ -5359,12 +5906,13 @@ const PermissionActionSchema = zod_mini.z.enum([
5359
5906
  "deny"
5360
5907
  ]);
5361
5908
  /**
5362
- * Whether a permission pattern is blankempty, or only whitespace. Shared
5363
- * with the import-side filter so the key the schema rejects and the key that
5364
- * filter removes can never drift apart.
5909
+ * Whether a key in a permission block a category name or a pattern — is
5910
+ * blank, that is empty or only whitespace. Shared with the import-side filter
5911
+ * so the key the schema rejects and the key that filter removes can never
5912
+ * drift apart.
5365
5913
  */
5366
- function isBlankPermissionPattern(pattern) {
5367
- return pattern.trim().length === 0;
5914
+ function isBlankPermissionKey(key) {
5915
+ return key.trim().length === 0;
5368
5916
  }
5369
5917
  /**
5370
5918
  * A single permission pattern key.
@@ -5376,7 +5924,18 @@ function isBlankPermissionPattern(pattern) {
5376
5924
  * silently ignores it. Rather than let each target decide, reject it here so
5377
5925
  * the mistake surfaces once, on the source file.
5378
5926
  */
5379
- const PermissionPatternSchema = zod_mini.z.string().check(zod_mini.z.refine((pattern) => !isBlankPermissionPattern(pattern), { message: "Permission pattern must not be blank" }));
5927
+ const PermissionPatternSchema = zod_mini.z.string().check(zod_mini.z.refine((pattern) => !isBlankPermissionKey(pattern), { message: "Permission pattern must not be blank" }));
5928
+ /**
5929
+ * A permission category key: the name of the tool surface a rules map applies
5930
+ * to (`bash`, `edit`, `webfetch`, ...).
5931
+ *
5932
+ * Blank is rejected for the same reason a blank pattern is, one step up. Every
5933
+ * translator reads categories by name, so `{"": {"git *": "allow"}}` reaches no
5934
+ * tool at all and the rules under it are silently dead — the mistake is only
5935
+ * visible as an entry missing from a generated config. Rejecting it here
5936
+ * surfaces it on the source file instead.
5937
+ */
5938
+ const PermissionCategorySchema = zod_mini.z.string().check(zod_mini.z.refine((category) => !isBlankPermissionKey(category), { message: "Permission category must not be blank" }));
5380
5939
  /**
5381
5940
  * Permission rules for a single tool category.
5382
5941
  * Keys are glob patterns matching tool input (commands, file paths, etc.).
@@ -5397,7 +5956,7 @@ const PermissionRulesSchema = zod_mini.z.record(PermissionPatternSchema, Permiss
5397
5956
  * @example
5398
5957
  * { "claudecode": { "permission": { "bash": { "git push *": "deny" } } } }
5399
5958
  */
5400
- const ToolScopedPermissionSchema = zod_mini.z.record(zod_mini.z.string(), PermissionRulesSchema);
5959
+ const ToolScopedPermissionSchema = zod_mini.z.record(PermissionCategorySchema, PermissionRulesSchema);
5401
5960
  /**
5402
5961
  * Generic tool-scoped override block for tools that have no tool-specific
5403
5962
  * override keys of their own; it carries only the canonical tool-scoped
@@ -5455,7 +6014,7 @@ const OpencodeOverridePermissionValueSchema = zod_mini.z.union([PermissionAction
5455
6014
  * @example
5456
6015
  * { "permission": { "external_directory": "deny", "webfetch": "allow" } }
5457
6016
  */
5458
- const OpencodePermissionsOverrideSchema = zod_mini.z.looseObject({ permission: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), OpencodeOverridePermissionValueSchema)) });
6017
+ const OpencodePermissionsOverrideSchema = zod_mini.z.looseObject({ permission: zod_mini.z.optional(zod_mini.z.record(PermissionCategorySchema, OpencodeOverridePermissionValueSchema)) });
5459
6018
  /**
5460
6019
  * Tool-scoped override block for Hermes Agent. Keys placed here are deep-merged
5461
6020
  * into Hermes's `~/.hermes/config.yaml` and never leak into other tools' configs.
@@ -5515,7 +6074,7 @@ const ClinePermissionsOverrideSchema = zod_mini.z.looseObject({
5515
6074
  * @see https://kilo.ai/docs/getting-started/settings/sandboxing
5516
6075
  */
5517
6076
  const KiloPermissionsOverrideSchema = zod_mini.z.looseObject({
5518
- permission: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), OpencodeOverridePermissionValueSchema)),
6077
+ permission: zod_mini.z.optional(zod_mini.z.record(PermissionCategorySchema, OpencodeOverridePermissionValueSchema)),
5519
6078
  sandbox: zod_mini.z.optional(zod_mini.z.looseObject({}))
5520
6079
  });
5521
6080
  /**
@@ -5586,7 +6145,7 @@ const ClaudecodePermissionsOverrideSchema = zod_mini.z.looseObject({
5586
6145
  * { "enabled_tools": ["bash", "read_file", "grep"] }
5587
6146
  */
5588
6147
  const VibePermissionsOverrideSchema = zod_mini.z.looseObject({
5589
- permission: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.looseObject({ sensitive_patterns: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())) }))),
6148
+ permission: zod_mini.z.optional(zod_mini.z.record(PermissionCategorySchema, zod_mini.z.looseObject({ sensitive_patterns: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())) }))),
5590
6149
  enabled_tools: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
5591
6150
  });
5592
6151
  /**
@@ -6320,12 +6879,12 @@ const ZedPermissionsOverrideSchema = zod_mini.z.looseObject({
6320
6879
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
6321
6880
  * Values are pattern-to-action mappings for that tool category.
6322
6881
  *
6323
- * The optional `opencode`/`hermes`/`cline`/`kilo`/`claudecode`/`vibe`/`cursor`/
6324
- * `qwencode`/`reasonix`/`factorydroid`/`warp`/`junie`/`takt`/`amp`/
6325
- * `antigravity-cli`/`augmentcode`/`kiro`/`codexcli`/`zed` keys are tool-scoped
6326
- * overrides consumed only by their respective translator (see the matching
6327
- * `*PermissionsOverrideSchema`); every other tool reads the shared `permission`
6328
- * block and ignores them.
6882
+ * The optional tool keys below are tool-scoped overrides consumed only by their
6883
+ * respective translator (see the matching `*PermissionsOverrideSchema`); every
6884
+ * other tool reads the shared `permission` block and ignores them. The set of
6885
+ * keys is exactly `permissionsProcessorToolTargetTuple` mapped through
6886
+ * `PERMISSION_OVERRIDE_KEY_ALIASES` a test asserts that, so this comment does
6887
+ * not enumerate them and go stale.
6329
6888
  *
6330
6889
  * Additionally, every permissions-capable tool accepts a canonical tool-scoped
6331
6890
  * `permission` block under its override key (`{toolname}.permission`, same
@@ -6343,7 +6902,7 @@ const ZedPermissionsOverrideSchema = zod_mini.z.looseObject({
6343
6902
  * }
6344
6903
  */
6345
6904
  const PermissionsConfigSchema = zod_mini.z.looseObject({
6346
- permission: zod_mini.z.record(zod_mini.z.string(), PermissionRulesSchema),
6905
+ permission: zod_mini.z.record(PermissionCategorySchema, PermissionRulesSchema),
6347
6906
  opencode: zod_mini.z.optional(OpencodePermissionsOverrideSchema),
6348
6907
  hermes: zod_mini.z.optional(HermesPermissionsOverrideSchema),
6349
6908
  cline: zod_mini.z.optional(ClinePermissionsOverrideSchema),
@@ -6372,7 +6931,9 @@ const PermissionsConfigSchema = zod_mini.z.looseObject({
6372
6931
  goose: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
6373
6932
  grokcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
6374
6933
  "kimi-code": zod_mini.z.optional(KimiCodePermissionsOverrideSchema),
6375
- rovodev: zod_mini.z.optional(CanonicalPermissionsOverrideSchema)
6934
+ roo: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
6935
+ rovodev: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
6936
+ zoocode: zod_mini.z.optional(CanonicalPermissionsOverrideSchema)
6376
6937
  });
6377
6938
  /**
6378
6939
  * Full permissions file schema including optional $schema field.
@@ -6414,10 +6975,40 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
6414
6975
  }]
6415
6976
  };
6416
6977
  }
6978
+ /**
6979
+ * The canonical document an importer produces from a tool's own config.
6980
+ *
6981
+ * Every importer has to run the blank-key filter over what it is about to
6982
+ * write: the canonical schema rejects a blank pattern and a blank category
6983
+ * outright, so a source file carrying either would be refused by the very
6984
+ * next `generate` — and that refusal takes the whole file with it, so one
6985
+ * blank key imported from one tool stops every tool's permissions from being
6986
+ * generated. Building the imported document here rather than calling the
6987
+ * filter beside each `new RulesyncPermissions(...)` is what keeps the next
6988
+ * importer from forgetting it.
6989
+ *
6990
+ * `sourcePath` is the tool config being read, used only to name it if
6991
+ * something is dropped.
6992
+ */
6993
+ static fromImportedFileContent({ outputRoot, fileContent, sourcePath, logger }) {
6994
+ return new RulesyncPermissions({
6995
+ outputRoot,
6996
+ relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
6997
+ relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
6998
+ fileContent: withoutBlankPermissionKeys({
6999
+ fileContent,
7000
+ sourcePath,
7001
+ logger
7002
+ })
7003
+ });
7004
+ }
6417
7005
  validate() {
6418
7006
  if (this.droppedKeys.length > 0) return {
6419
7007
  success: false,
6420
- error: /* @__PURE__ */ new Error(`${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)} uses ${this.droppedKeys.join(", ")} as ${this.droppedKeys.length === 1 ? "a key" : "keys"}. Rulesync removes __proto__, constructor and prototype from every source document it parses, because assigning them would reach the prototype chain instead of the object. They are therefore never written to any tool's config — rename them rather than leaving entries that silently do nothing.`)
7008
+ error: droppedPollutionKeysError({
7009
+ sourcePath: this.getRelativePathFromCwd(),
7010
+ droppedKeys: this.droppedKeys
7011
+ })
6421
7012
  };
6422
7013
  const result = RulesyncPermissionsFileSchema.safeParse(this.json);
6423
7014
  if (!result.success) return {
@@ -6489,33 +7080,62 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
6489
7080
  }
6490
7081
  };
6491
7082
  /**
6492
- * Strip every blank permission pattern from an already-parsed canonical
6493
- * document, reporting how many were dropped from each block.
7083
+ * Tool-scoped override keys whose `permission` block maps a category to
7084
+ * something other than a pattern map. Vibe alone keeps
7085
+ * `{ sensitive_patterns: [...] }` objects there, so the keys one level down are
7086
+ * field names and the blank-pattern filter must not walk them. The category
7087
+ * names above them are still category names, and are filtered like any other.
7088
+ *
7089
+ * Typed as `ToolTarget` so a renamed target fails to compile here rather than
7090
+ * silently stopping to match, which would let the filter start deleting Vibe's
7091
+ * fields and reporting them as removed permission patterns. That only holds for
7092
+ * targets whose override key is the target name itself: a target that aliases
7093
+ * to another key (see `PERMISSION_OVERRIDE_KEY_ALIASES`) would have to be listed
7094
+ * under the alias, which this type would reject. None of them is non-pattern-map
7095
+ * today, so add that spelling only when one becomes so.
7096
+ */
7097
+ const NON_PATTERN_MAP_PERMISSION_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["vibe"]);
7098
+ /**
7099
+ * Strip every blank key — category or pattern — from an already-parsed
7100
+ * canonical document, reporting how many were dropped from each block.
6494
7101
  *
6495
7102
  * Both the shared `permission` block and every tool-scoped
6496
7103
  * `{toolname}.permission` block are walked, because import produces both:
6497
7104
  * OpenCode and Kilo route their tool-only categories into the tool-scoped block
6498
- * verbatim, so a blank pattern in the user's own config lands there. A category
6499
- * whose value is not a rules map is left exactly as it is, which is what keeps
6500
- * the tool-native shapes intact — OpenCode's and Kilo's bare action strings
6501
- * (`"external_directory": "deny"`) have no pattern key to inspect, Vibe's
6502
- * `sensitive_patterns` objects carry no blank key, and Kilo's `sandbox` is not a
6503
- * `permission` block at all.
6504
- */
6505
- function stripBlankPermissionPatterns(config) {
6506
- const removed = /* @__PURE__ */ new Map();
6507
- const filterBlock = ({ block, blockPath }) => {
7105
+ * verbatim, so a blank key in the user's own config lands there. A category
7106
+ * whose value is not a rules map keeps its value exactly as it is, which is what
7107
+ * keeps the tool-native shapes intact — OpenCode's and Kilo's bare action
7108
+ * strings (`"external_directory": "deny"`) have no pattern key to inspect, and
7109
+ * Kilo's `sandbox` is not a `permission` block at all.
7110
+ *
7111
+ * Categories are filtered for the same reason patterns are, one level up: the
7112
+ * canonical schema rejects a blank category, so reproducing one would write a
7113
+ * source file the very next `generate` refuses — and it would refuse the whole
7114
+ * file, taking every tool's permissions generation down with it.
7115
+ *
7116
+ * The patterns inside {@link NON_PATTERN_MAP_PERMISSION_OVERRIDE_KEYS} blocks
7117
+ * are left alone: they are field names rather than patterns there. Their
7118
+ * category names are still filtered.
7119
+ */
7120
+ function stripBlankPermissionKeys(config) {
7121
+ const patterns = /* @__PURE__ */ new Map();
7122
+ const categories = /* @__PURE__ */ new Map();
7123
+ const filterBlock = ({ block, blockPath, filterPatterns }) => {
6508
7124
  const filtered = {};
6509
7125
  for (const [category, rules] of Object.entries(block)) {
6510
- if (!isRecord$1(rules)) {
7126
+ if (isBlankPermissionKey(category)) {
7127
+ categories.set(blockPath, (categories.get(blockPath) ?? 0) + 1);
7128
+ continue;
7129
+ }
7130
+ if (!filterPatterns || !isRecord$1(rules)) {
6511
7131
  filtered[category] = rules;
6512
7132
  continue;
6513
7133
  }
6514
7134
  const kept = {};
6515
7135
  for (const [pattern, action] of Object.entries(rules)) {
6516
- if (isBlankPermissionPattern(pattern)) {
7136
+ if (isBlankPermissionKey(pattern)) {
6517
7137
  const path = `${blockPath}.${category}`;
6518
- removed.set(path, (removed.get(path) ?? 0) + 1);
7138
+ patterns.set(path, (patterns.get(path) ?? 0) + 1);
6519
7139
  continue;
6520
7140
  }
6521
7141
  kept[pattern] = action;
@@ -6528,25 +7148,41 @@ function stripBlankPermissionPatterns(config) {
6528
7148
  const next = { ...config };
6529
7149
  if (isRecord$1(config.permission)) next.permission = filterBlock({
6530
7150
  block: config.permission,
6531
- blockPath: "permission"
7151
+ blockPath: "permission",
7152
+ filterPatterns: true
6532
7153
  });
6533
7154
  for (const [key, value] of Object.entries(config)) {
6534
7155
  if (key === "permission" || !isRecord$1(value) || !isRecord$1(value.permission)) continue;
6535
- next[key] = {
6536
- ...value,
6537
- permission: filterBlock({
6538
- block: value.permission,
6539
- blockPath: `${key}.permission`
6540
- })
6541
- };
7156
+ const permission = filterBlock({
7157
+ block: value.permission,
7158
+ blockPath: `${key}.permission`,
7159
+ filterPatterns: !NON_PATTERN_MAP_PERMISSION_OVERRIDE_KEYS.has(key)
7160
+ });
7161
+ if (!(Object.keys(permission).length === 0 && Object.keys(value.permission).length > 0)) {
7162
+ next[key] = {
7163
+ ...value,
7164
+ permission
7165
+ };
7166
+ continue;
7167
+ }
7168
+ const { permission: _emptied, ...rest } = value;
7169
+ if (Object.keys(rest).length === 0) {
7170
+ delete next[key];
7171
+ continue;
7172
+ }
7173
+ next[key] = rest;
6542
7174
  }
6543
7175
  return {
6544
7176
  config: next,
6545
- removed
7177
+ removed: {
7178
+ patterns,
7179
+ categories
7180
+ }
6546
7181
  };
6547
7182
  }
7183
+ const summarizeDroppedCounts = (counts) => [...counts.entries()].map(([path, count]) => `${count} in ${JSON.stringify(path)}`).join(", ");
6548
7184
  /**
6549
- * Report the dropped patterns.
7185
+ * Report the dropped keys.
6550
7186
  *
6551
7187
  * Dropping an entry silently is the failure mode a permissions source must not
6552
7188
  * have. A blanket blank pattern can read as "deny everything by default";
@@ -6557,27 +7193,36 @@ function stripBlankPermissionPatterns(config) {
6557
7193
  * `logger` is optional because the import direction (`toRulesyncPermissions`)
6558
7194
  * takes no logger parameter; the shared `fallbackLogger` is configured from the
6559
7195
  * CLI flags and the resolved config, so `silent` is still honored.
7196
+ *
7197
+ * `sourcePath` names the tool config the keys came out of. A single import run
7198
+ * reads many tools, and the block paths alone (`permission.bash`) are the same
7199
+ * for all of them, so without it the user is told something was dropped but not
7200
+ * from where.
6560
7201
  */
6561
- function warnAboutDroppedPatterns({ removed, logger }) {
6562
- warnWithFallback(logger, `Dropped blank permission patterns while reading a tool's permission configuration (${[...removed.entries()].map(([path, count]) => `${count} in "${path}"`).join(", ")}). An empty or whitespace-only pattern matches everything, and tools disagree on what it means — some apply it to every command, others ignore it entirely — so it is not carried into the rulesync permissions config. If one of them was a blanket deny, the imported configuration now allows more than the file it came from; re-add it with a real pattern.`);
7202
+ function warnAboutDroppedKeys({ removed, sourcePath, logger }) {
7203
+ const source = sourcePath === void 0 ? "a tool's permission configuration" : JSON.stringify(sourcePath);
7204
+ if (removed.patterns.size > 0) warnWithFallback(logger, `Dropped blank permission patterns while reading ${source} (${summarizeDroppedCounts(removed.patterns)}). An empty or whitespace-only pattern matches everything, and tools disagree on what it means — some apply it to every command, others ignore it entirely — so it is not carried into the rulesync permissions config. If one of them was a blanket deny, the imported configuration now allows more than the file it came from; re-add it with a real pattern.`);
7205
+ if (removed.categories.size > 0) warnWithFallback(logger, `Dropped blank permission categories while reading ${source} (${summarizeDroppedCounts(removed.categories)}). A category name is how every tool finds the rules underneath it, so an empty or whitespace-only one reaches no tool at all and the rules below it were never going to be generated. Carrying one into the rulesync permissions config would make the next generate refuse the whole file, so it is removed here; re-add those rules under a real category name.`);
6563
7206
  }
6564
7207
  /**
6565
- * Drop blank permission patterns from a canonical document produced by import.
7208
+ * Drop blank permission keys from a canonical document produced by import.
6566
7209
  *
6567
- * The canonical schema rejects a blank pattern outright, and every tool that
6568
- * has one in its own config already treats it as something other than a real
6569
- * pattern (Roo Code, for instance, keeps only entries passing
6570
- * `cmd.trim().length > 0`). Reproducing one in `.rulesync/permissions.jsonc`
6571
- * would therefore write a source file that the very next `generate` refuses —
6572
- * so it is removed here instead, and reported.
7210
+ * The canonical schema rejects a blank pattern and a blank category outright,
7211
+ * and every tool that has a blank pattern in its own config already treats it as
7212
+ * something other than a real pattern (Roo Code, for instance, keeps only
7213
+ * entries passing `cmd.trim().length > 0`). Reproducing either in
7214
+ * `.rulesync/permissions.jsonc` would therefore write a source file that the
7215
+ * very next `generate` refuses the whole file, not just that entry — so they
7216
+ * are removed here instead, and reported.
6573
7217
  */
6574
- function withoutBlankPermissionPatterns({ fileContent, logger }) {
7218
+ function withoutBlankPermissionKeys({ fileContent, sourcePath, logger }) {
6575
7219
  const parsed = parseJsonc$8(fileContent);
6576
7220
  if (!isRecord$1(parsed)) return fileContent;
6577
- const { config, removed } = stripBlankPermissionPatterns(parsed);
6578
- if (removed.size === 0) return fileContent;
6579
- warnAboutDroppedPatterns({
7221
+ const { config, removed } = stripBlankPermissionKeys(parsed);
7222
+ if (removed.patterns.size === 0 && removed.categories.size === 0) return fileContent;
7223
+ warnAboutDroppedKeys({
6580
7224
  removed,
7225
+ sourcePath,
6581
7226
  logger
6582
7227
  });
6583
7228
  return JSON.stringify(config, null, 2);
@@ -6586,14 +7231,15 @@ function withoutBlankPermissionPatterns({ fileContent, logger }) {
6586
7231
  * The same filter over an already-parsed document, for callers that validate a
6587
7232
  * canonical block before it is ever serialized. Hermes Agent stores its
6588
7233
  * rulesync provenance inside its own config and parses it back on import; left
6589
- * unfiltered, one blank pattern would fail `safeParse` and discard the entire
6590
- * provenance block without a word.
7234
+ * unfiltered, one blank pattern or category would fail `safeParse` and discard
7235
+ * the entire provenance block without a word.
6591
7236
  */
6592
- function withoutBlankPermissionPatternsIn({ config, logger }) {
6593
- const { config: filtered, removed } = stripBlankPermissionPatterns(config);
6594
- if (removed.size === 0) return config;
6595
- warnAboutDroppedPatterns({
7237
+ function withoutBlankPermissionKeysIn({ config, sourcePath, logger }) {
7238
+ const { config: filtered, removed } = stripBlankPermissionKeys(config);
7239
+ if (removed.patterns.size === 0 && removed.categories.size === 0) return config;
7240
+ warnAboutDroppedKeys({
6596
7241
  removed,
7242
+ sourcePath,
6597
7243
  logger
6598
7244
  });
6599
7245
  return filtered;
@@ -6742,6 +7388,24 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
6742
7388
  //#endregion
6743
7389
  //#region src/types/ai-dir.ts
6744
7390
  /**
7391
+ * Whether `name` is a path rather than a single name.
7392
+ *
7393
+ * Both separators are rejected on every platform, which is why neither
7394
+ * `path.sep` nor the platform is consulted: a backslash is a legal character in
7395
+ * a POSIX name, but a name carrying one is a path the moment it reaches
7396
+ * Windows, and `AiDir` names travel between the two — most tools take one
7397
+ * straight from a skill's frontmatter.
7398
+ *
7399
+ * Exported because the checks that run *before* a name reaches `AiDir` — so
7400
+ * that an unusable name is reported and skipped rather than thrown over — have
7401
+ * to reject exactly the set this guard does. Two spellings of one rule drift
7402
+ * apart the moment either is tightened, and the pre-filter drifting narrower
7403
+ * turns a reported name back into a failed run.
7404
+ */
7405
+ function containsPathSeparator(name) {
7406
+ return name.includes("/") || name.includes("\\");
7407
+ }
7408
+ /**
6745
7409
  * Directories that hold credentials. Excluding these protects something, so
6746
7410
  * their exclusion is reported rather than silent.
6747
7411
  */
@@ -7201,7 +7865,7 @@ var AiDir = class AiDir {
7201
7865
  */
7202
7866
  global;
7203
7867
  constructor({ outputRoot = process.cwd(), relativeDirPath, dirName, mainFile, otherFiles = [], global = false }) {
7204
- if (dirName.includes(node_path.default.sep) || dirName.includes("/") || dirName.includes("\\")) throw new Error(`Directory name cannot contain path separators: dirName="${dirName}"`);
7868
+ if (containsPathSeparator(dirName)) throw new Error(`Directory name cannot contain path separators: dirName="${dirName}"`);
7205
7869
  if (dirName === "" || dirName === "." || dirName === "..") throw new Error(`Directory name cannot be empty, ".", or "..": dirName="${dirName}"`);
7206
7870
  this.outputRoot = outputRoot;
7207
7871
  this.relativeDirPath = relativeDirPath;
@@ -7262,7 +7926,7 @@ var AiDir = class AiDir {
7262
7926
  const mainFile = this.getMainFile();
7263
7927
  if (mainFile === void 0) return;
7264
7928
  const name = mainFile.name;
7265
- if (name === "" || name === "." || name === ".." || name.includes("/") || name.includes("\\")) return;
7929
+ if (name === "" || name === "." || name === ".." || containsPathSeparator(name)) return;
7266
7930
  return node_path.default.join(this.getDirPath(), name);
7267
7931
  }
7268
7932
  getDirPath() {
@@ -7593,7 +8257,7 @@ var AiDir = class AiDir {
7593
8257
  * differ from the path the run claims, which turns it into an orphan.
7594
8258
  */
7595
8259
  function isUnsafeSkillDirName(name) {
7596
- return name === "" || name === "." || name === ".." || name.includes("/") || name.includes("\\") || name.endsWith(".") || name.endsWith(" ");
8260
+ return name === "" || name === "." || name === ".." || containsPathSeparator(name) || name.endsWith(".") || name.endsWith(" ");
7597
8261
  }
7598
8262
  const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
7599
8263
  name: zod_mini.z.string().check(zod_mini.z.refine((name) => !isUnsafeSkillDirName(name), { message: "name is used as a directory name: it may not be empty, \".\" or \"..\", contain a path separator, or end with a dot or a space" })),
@@ -7853,6 +8517,16 @@ const RulesyncSubagentFrontmatterSchema = zod_mini.z.looseObject({
7853
8517
  zoocode: zod_mini.z.optional(zod_mini.z.looseObject({
7854
8518
  /** Per-mode MCP server allowlist (Zoo Code v3.60.0+); omitted = all. */
7855
8519
  allowedMcpServers: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())) })),
8520
+ zcode: zod_mini.z.optional(zod_mini.z.looseObject({
8521
+ model: zod_mini.z.optional(zod_mini.z.string()),
8522
+ thoughtLevel: zod_mini.z.optional(zod_mini.z.string()),
8523
+ color: zod_mini.z.optional(zod_mini.z.string()),
8524
+ tools: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8525
+ disallowedTools: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8526
+ maxTurns: zod_mini.z.optional(zod_mini.z.number().check(zod_mini.z.int(), zod_mini.z.positive())),
8527
+ injectAgentsMd: zod_mini.z.optional(zod_mini.z.boolean()),
8528
+ mcpServers: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
8529
+ })),
7856
8530
  vibe: zod_mini.z.optional(zod_mini.z.looseObject({
7857
8531
  agent_type: zod_mini.z.optional(zod_mini.z.enum(["agent", "subagent"])),
7858
8532
  display_name: zod_mini.z.optional(zod_mini.z.string()),
@@ -7940,10 +8614,12 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
7940
8614
  * lets a directory be *created* with a backslash in its name, so such a
7941
8615
  * directory can sit in a skills root that nothing here can name — it is
7942
8616
  * reported rather than passed on, since the alternative is a candidate built
7943
- * from a name that belongs to no directory at all.
8617
+ * from a name that belongs to no directory at all. The test is `AiDir`'s own,
8618
+ * so this pre-filter cannot come to accept a name the guard behind it throws
8619
+ * over.
7944
8620
  */
7945
8621
  function isAddressableSkillName(name) {
7946
- return !name.includes("/") && !name.includes("\\");
8622
+ return !containsPathSeparator(name);
7947
8623
  }
7948
8624
  /**
7949
8625
  * The names of the skill directories directly under `skillsRoot`.
@@ -9334,18 +10010,168 @@ function splitCheckFile({ fileContent, fallbackName }) {
9334
10010
  });
9335
10011
  }
9336
10012
  //#endregion
10013
+ //#region src/features/checks/aggregated-tool-check.ts
10014
+ /**
10015
+ * Shared skeleton for the checks adapters whose output is a single aggregated
10016
+ * instruction file — one file whose sections are the marked blocks
10017
+ * `aggregated-check-file.ts` renders and splits, rather than a directory with a
10018
+ * file per check.
10019
+ *
10020
+ * That module already held the rendering and the splitting; what is here is the
10021
+ * adapter around it, which was near-verbatim in Cursor Bugbot, Rovo Dev and
10022
+ * Factory Droid. A subclass supplies {@link ToolCheck.getSettablePaths} and
10023
+ * {@link getAggregatedCheckConfig}, and inherits the rest — so a fix to how
10024
+ * these files are read or written lands once instead of three times.
10025
+ *
10026
+ * `fromRulesyncCheck` is refused here rather than implemented: sections share
10027
+ * one file, so an output cannot be produced from one check in isolation and the
10028
+ * processor calls {@link fromRulesyncChecks} instead.
10029
+ *
10030
+ * Not declared `abstract`, even though nothing instantiates it directly: the
10031
+ * statics below build the subclass with `new this(...)`, which an abstract
10032
+ * constructor type forbids. What a subclass owes is enforced the way the rest
10033
+ * of this codebase enforces it on a static — {@link getAggregatedCheckConfig}
10034
+ * throws until it is overridden.
10035
+ */
10036
+ var AggregatedToolCheck = class extends ToolCheck {
10037
+ /**
10038
+ * The per-tool values the shared skeleton reads. Thrown rather than abstract
10039
+ * because TypeScript has no abstract statics; a subclass that forgets it
10040
+ * fails on its first use rather than silently taking a default.
10041
+ */
10042
+ static getAggregatedCheckConfig() {
10043
+ throw new Error("Please implement this method in the subclass.");
10044
+ }
10045
+ /**
10046
+ * The settable paths with the file name required. An aggregated adapter names
10047
+ * the one file it writes — that is what keeps consumers which would otherwise
10048
+ * claim the whole tool directory, the gitignore derivation among them,
10049
+ * narrowed to it — so a missing name is a mistake in the subclass rather than
10050
+ * a case to fall back for.
10051
+ */
10052
+ static getAggregatedPaths({ global = false } = {}) {
10053
+ const paths = this.getSettablePaths({ global });
10054
+ if (!paths.relativeFilePath) throw new Error(`${this.name} writes one aggregated file, so getSettablePaths must name it.`);
10055
+ return {
10056
+ relativeDirPath: paths.relativeDirPath,
10057
+ relativeFilePath: paths.relativeFilePath
10058
+ };
10059
+ }
10060
+ static getAggregatedFilePath({ outputRoot, global = false }) {
10061
+ const paths = this.getAggregatedPaths({ global });
10062
+ return {
10063
+ ...paths,
10064
+ filePath: (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)
10065
+ };
10066
+ }
10067
+ static isTargetedByRulesyncCheck(rulesyncCheck) {
10068
+ return this.isTargetedByRulesyncCheckDefault({
10069
+ rulesyncCheck,
10070
+ toolTarget: this.getAggregatedCheckConfig().toolTarget
10071
+ });
10072
+ }
10073
+ /**
10074
+ * Ownership guard the processor consults before it deletes anything for this
10075
+ * tool. Every one of these paths is one a user may well have written by hand,
10076
+ * whether because the tool documents it as the place to write review
10077
+ * instructions or because it is shared with something else the tool loads, so
10078
+ * anything in it that rulesync did not write is not rulesync's to remove — dropping the last check targeting the tool must not
10079
+ * take somebody's review instructions with it. Deletion is therefore allowed
10080
+ * only for a file that is nothing but generated sections: one that carries no
10081
+ * marker at all, or that carries hand-written text ahead of the first marker,
10082
+ * stays.
10083
+ */
10084
+ static async canDeleteAuxiliaryFiles({ outputRoot }) {
10085
+ const { filePath } = this.getAggregatedFilePath({ outputRoot });
10086
+ const fileContent = await readFileContentOrNull(filePath);
10087
+ if (fileContent === null) return true;
10088
+ return isOnlyGeneratedSections(fileContent);
10089
+ }
10090
+ static fromRulesyncCheck(_params) {
10091
+ const { displayName } = this.getAggregatedCheckConfig();
10092
+ throw new Error(`${displayName} checks are built from all checks at once; use fromRulesyncChecks.`);
10093
+ }
10094
+ static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
10095
+ if (rulesyncChecks.length === 0) return [];
10096
+ const config = this.getAggregatedCheckConfig();
10097
+ const { relativeDirPath, relativeFilePath, filePath } = this.getAggregatedFilePath({
10098
+ outputRoot,
10099
+ global
10100
+ });
10101
+ if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) {
10102
+ if (config.handWrittenPreamble === "skip") {
10103
+ logger?.warn(config.handWrittenWarning({
10104
+ filePath,
10105
+ displayName: config.displayName,
10106
+ toolTarget: config.toolTarget
10107
+ }));
10108
+ return [];
10109
+ }
10110
+ logger?.warn(`${config.displayName} checks: ${filePath} holds instructions rulesync did not write, and generating replaces the whole file. Run \`rulesync import --targets ${config.toolTarget} --features checks\` first to keep them.`);
10111
+ }
10112
+ return [new this({
10113
+ outputRoot,
10114
+ relativeDirPath,
10115
+ relativeFilePath,
10116
+ fileContent: renderCheckFile(rulesyncChecks),
10117
+ global
10118
+ })];
10119
+ }
10120
+ static async fromFile({ outputRoot = process.cwd(), global = false }) {
10121
+ const { relativeDirPath, relativeFilePath, filePath } = this.getAggregatedFilePath({
10122
+ outputRoot,
10123
+ global
10124
+ });
10125
+ return new this({
10126
+ outputRoot,
10127
+ relativeDirPath,
10128
+ relativeFilePath,
10129
+ fileContent: await readFileContentOrNull(filePath) ?? "",
10130
+ global
10131
+ });
10132
+ }
10133
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
10134
+ return new this({
10135
+ outputRoot,
10136
+ relativeDirPath,
10137
+ relativeFilePath,
10138
+ fileContent: "",
10139
+ validate: false,
10140
+ global
10141
+ });
10142
+ }
10143
+ validate() {
10144
+ return {
10145
+ success: true,
10146
+ error: null
10147
+ };
10148
+ }
10149
+ toRulesyncCheck() {
10150
+ const first = this.toRulesyncChecks()[0];
10151
+ if (!first) throw new Error(`No check instructions found in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
10152
+ return first;
10153
+ }
10154
+ toRulesyncChecks() {
10155
+ const config = this.constructor.getAggregatedCheckConfig();
10156
+ const fileContent = this.getFileContent();
10157
+ return splitCheckFile({
10158
+ fileContent: config.transformImportedContent?.(fileContent) ?? fileContent,
10159
+ fallbackName: config.fallbackCheckName
10160
+ });
10161
+ }
10162
+ };
10163
+ //#endregion
9337
10164
  //#region src/features/checks/cursor-check.ts
9338
- const FALLBACK_CHECK_NAME$2 = "bugbot";
9339
10165
  /**
9340
10166
  * Checks adapter for Cursor Bugbot (`.cursor/BUGBOT.md`).
9341
10167
  *
9342
10168
  * Bugbot takes one aggregated instruction file per directory rather than a file
9343
10169
  * per check, so every `.rulesync/checks/*.md` targeting Cursor collapses into
9344
- * the repository-root `.cursor/BUGBOT.md` — hence {@link fromRulesyncChecks}
9345
- * rather than the usual per-check conversion. Each check becomes one section:
9346
- * an HTML-comment marker carrying the check name, an `## <name>` heading, and
9347
- * the check body as the instruction text (the `description` is used when the
9348
- * body is empty).
10170
+ * the repository-root `.cursor/BUGBOT.md` — hence `fromRulesyncChecks` rather
10171
+ * than the usual per-check conversion, which {@link AggregatedToolCheck}
10172
+ * provides. Each check becomes one section: an HTML-comment marker carrying the
10173
+ * check name, an `## <name>` heading, and the check body as the instruction
10174
+ * text (the `description` is used when the body is empty).
9349
10175
  *
9350
10176
  * Bugbot reads the file as free prose, so a check's `severity` and `tools` have
9351
10177
  * no equivalent there: they are not written and do not come back on import. So
@@ -9361,97 +10187,26 @@ const FALLBACK_CHECK_NAME$2 = "bugbot";
9361
10187
  * before the first marker — and a hand-written file with no markers at all —
9362
10188
  * becomes a single `bugbot` check, so nothing in the file is dropped. A file
9363
10189
  * holding anything rulesync did not write is never deleted either (see
9364
- * {@link canDeleteAuxiliaryFiles}), though generating checks for Cursor does
9365
- * replace it — import first to keep what is there, which is warned about.
10190
+ * `canDeleteAuxiliaryFiles` on the base), though generating checks for Cursor
10191
+ * does replace it — import first to keep what is there, which is warned about.
9366
10192
  *
9367
10193
  * @see https://cursor.com/docs/bugbot
9368
10194
  */
9369
- var CursorCheck = class CursorCheck extends ToolCheck {
10195
+ var CursorCheck = class extends AggregatedToolCheck {
9370
10196
  static getSettablePaths(_options = {}) {
9371
10197
  return {
9372
10198
  relativeDirPath: CURSOR_DIR,
9373
10199
  relativeFilePath: CURSOR_BUGBOT_FILE_NAME
9374
10200
  };
9375
10201
  }
9376
- static isTargetedByRulesyncCheck(rulesyncCheck) {
9377
- return this.isTargetedByRulesyncCheckDefault({
9378
- rulesyncCheck,
9379
- toolTarget: "cursor"
9380
- });
9381
- }
9382
- /**
9383
- * Ownership guard the processor consults before it deletes anything for this
9384
- * tool. `.cursor/BUGBOT.md` is a file Cursor's own documentation tells users
9385
- * to hand-write, so anything in it that rulesync did not write is not
9386
- * rulesync's to remove — dropping the last check targeting Cursor must not
9387
- * take somebody's hand-written review instructions with it. Deletion is
9388
- * therefore allowed only for a file that is nothing but generated sections:
9389
- * one that carries no marker at all, or that carries hand-written text ahead
9390
- * of the first marker, stays.
9391
- */
9392
- static async canDeleteAuxiliaryFiles({ outputRoot }) {
9393
- const paths = CursorCheck.getSettablePaths();
9394
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath ?? "BUGBOT.md"));
9395
- if (fileContent === null) return true;
9396
- return isOnlyGeneratedSections(fileContent);
9397
- }
9398
- static fromRulesyncCheck(_params) {
9399
- throw new Error("Cursor checks are built from all checks at once; use fromRulesyncChecks.");
9400
- }
9401
- static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
9402
- if (rulesyncChecks.length === 0) return [];
9403
- const paths = CursorCheck.getSettablePaths({ global });
9404
- const relativeFilePath = paths.relativeFilePath ?? "BUGBOT.md";
9405
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
9406
- if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) logger?.warn(`Cursor checks: ${filePath} holds instructions rulesync did not write, and generating replaces the whole file. Run \`rulesync import --targets cursor --features checks\` first to keep them.`);
9407
- const fileContent = renderCheckFile(rulesyncChecks);
9408
- return [new CursorCheck({
9409
- outputRoot,
9410
- relativeDirPath: paths.relativeDirPath,
9411
- relativeFilePath,
9412
- fileContent,
9413
- global
9414
- })];
9415
- }
9416
- static async fromFile({ outputRoot = process.cwd(), global = false }) {
9417
- const paths = CursorCheck.getSettablePaths({ global });
9418
- const relativeFilePath = paths.relativeFilePath ?? "BUGBOT.md";
9419
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
9420
- return new CursorCheck({
9421
- outputRoot,
9422
- relativeDirPath: paths.relativeDirPath,
9423
- relativeFilePath,
9424
- fileContent: await readFileContentOrNull(filePath) ?? "",
9425
- global
9426
- });
9427
- }
9428
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
9429
- return new CursorCheck({
9430
- outputRoot,
9431
- relativeDirPath,
9432
- relativeFilePath,
9433
- fileContent: "",
9434
- validate: false,
9435
- global
9436
- });
9437
- }
9438
- validate() {
10202
+ static getAggregatedCheckConfig() {
9439
10203
  return {
9440
- success: true,
9441
- error: null
10204
+ displayName: "Cursor",
10205
+ toolTarget: "cursor",
10206
+ fallbackCheckName: "bugbot",
10207
+ handWrittenPreamble: "replace"
9442
10208
  };
9443
10209
  }
9444
- toRulesyncCheck() {
9445
- const first = this.toRulesyncChecks()[0];
9446
- if (!first) throw new Error(`No check instructions found in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
9447
- return first;
9448
- }
9449
- toRulesyncChecks() {
9450
- return splitCheckFile({
9451
- fileContent: this.getFileContent(),
9452
- fallbackName: FALLBACK_CHECK_NAME$2
9453
- });
9454
- }
9455
10210
  };
9456
10211
  //#endregion
9457
10212
  //#region src/constants/factorydroid-paths.ts
@@ -9488,7 +10243,6 @@ const FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME = "review-guidelines";
9488
10243
  const FACTORYDROID_REVIEW_GUIDELINES_DIR_PATH = (0, node_path.join)(FACTORYDROID_SKILLS_DIR_PATH, FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME);
9489
10244
  //#endregion
9490
10245
  //#region src/features/checks/factorydroid-check.ts
9491
- const FALLBACK_CHECK_NAME$1 = FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME;
9492
10246
  /**
9493
10247
  * Drop the YAML frontmatter of a hand-authored `review-guidelines` skill before
9494
10248
  * the file is split into checks.
@@ -9523,6 +10277,9 @@ function stripSkillFrontmatter(fileContent) {
9523
10277
  content = rest.slice(closing.index + closing[0].length).replace(LEADING_BLANK_LINE_PATTERN, "");
9524
10278
  }
9525
10279
  }
10280
+ function handWrittenWarning({ filePath, displayName, toolTarget }) {
10281
+ return `${displayName} checks: ${filePath} holds instructions rulesync did not write, so it is left as it is and no checks were generated for ${displayName}. Run \`rulesync import --targets ${toolTarget} --features checks\` to bring them into \`.rulesync/checks/\` and then delete the file, so the next generate writes it back from there; delete it outright if you no longer want it, or rename the directory if it is an ordinary skill rather than review guidelines. Importing alone leaves this file as it is, so it keeps blocking generation until it is gone. A rulesync skill named \`${FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME}\` is no longer generated here — Factory's reviewer reads this path, so the checks feature owns it — but a directory an older rulesync wrote can still be sitting there.`;
10282
+ }
9526
10283
  /**
9527
10284
  * Checks adapter for Factory Droid's code-review guidelines
9528
10285
  * (`.factory/skills/review-guidelines/SKILL.md`).
@@ -9531,9 +10288,10 @@ function stripSkillFrontmatter(fileContent) {
9531
10288
  * "repository-specific review guidelines" from a skill named
9532
10289
  * `review-guidelines` and injects them into every review run. That makes the
9533
10290
  * output a single aggregated file like Cursor Bugbot's and Rovo Dev's, so every
9534
- * `.rulesync/checks/*.md` targeting Factory Droid collapses into it via
9535
- * {@link fromRulesyncChecks}, each check written as a marked section (see
9536
- * `aggregated-check-file.ts` for the marker convention the adapters share).
10291
+ * `.rulesync/checks/*.md` targeting Factory Droid collapses into it via the
10292
+ * `fromRulesyncChecks` on {@link AggregatedToolCheck}, each check written as a
10293
+ * marked section (see `aggregated-check-file.ts` for the marker convention the
10294
+ * three aggregated adapters share).
9537
10295
  *
9538
10296
  * The file is plain Markdown with no frontmatter, matching Factory's documented
9539
10297
  * example. Frontmatter would also be self-defeating here: `renderCheckFile`
@@ -9551,8 +10309,9 @@ function stripSkillFrontmatter(fileContent) {
9551
10309
  * The output lives inside the same `.factory/skills/` tree the `skills` feature
9552
10310
  * writes, so a user-authored `review-guidelines` skill collides with it. The
9553
10311
  * path has one owner rather than a merge rule, and the owner is this feature:
9554
- * {@link fromRulesyncChecks} leaves a file holding anything rulesync did not
9555
- * write untouched, and {@link canDeleteAuxiliaryFiles} refuses to remove one.
10312
+ * generating leaves a file holding anything rulesync did not write untouched —
10313
+ * the `skip` policy below — and the base's `canDeleteAuxiliaryFiles` refuses to
10314
+ * remove one.
9556
10315
  * Their content is somebody's own writing and rulesync cannot reconstruct it,
9557
10316
  * so neither direction guesses. That is stricter than Cursor Bugbot's
9558
10317
  * replace-and-warn, and deliberately: `.cursor/BUGBOT.md` is a path only the
@@ -9562,92 +10321,23 @@ function stripSkillFrontmatter(fileContent) {
9562
10321
  *
9563
10322
  * @see https://docs.factory.ai/software-factory/code-review-ci
9564
10323
  */
9565
- var FactorydroidCheck = class FactorydroidCheck extends ToolCheck {
10324
+ var FactorydroidCheck = class extends AggregatedToolCheck {
9566
10325
  static getSettablePaths(_options = {}) {
9567
10326
  return {
9568
10327
  relativeDirPath: FACTORYDROID_REVIEW_GUIDELINES_DIR_PATH,
9569
10328
  relativeFilePath: SKILL_FILE_NAME
9570
10329
  };
9571
10330
  }
9572
- static isTargetedByRulesyncCheck(rulesyncCheck) {
9573
- return this.isTargetedByRulesyncCheckDefault({
9574
- rulesyncCheck,
9575
- toolTarget: "factorydroid"
9576
- });
9577
- }
9578
- /**
9579
- * Ownership guard the processor consults before it deletes anything for this
9580
- * tool. `review-guidelines` is an ordinary skill directory a user may have
9581
- * authored by hand, so dropping the last check targeting Factory Droid must
9582
- * not take their review instructions with it. Deletion is allowed only for a
9583
- * file that is nothing but generated sections.
9584
- */
9585
- static async canDeleteAuxiliaryFiles({ outputRoot }) {
9586
- const paths = FactorydroidCheck.getSettablePaths();
9587
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath ?? "SKILL.md"));
9588
- if (fileContent === null) return true;
9589
- return isOnlyGeneratedSections(fileContent);
9590
- }
9591
- static fromRulesyncCheck(_params) {
9592
- throw new Error("Factory Droid checks are built from all checks at once; use fromRulesyncChecks.");
9593
- }
9594
- static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
9595
- if (rulesyncChecks.length === 0) return [];
9596
- const paths = FactorydroidCheck.getSettablePaths({ global });
9597
- const relativeFilePath = paths.relativeFilePath ?? "SKILL.md";
9598
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
9599
- if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) {
9600
- logger?.warn(`Factory Droid checks: ${filePath} holds instructions rulesync did not write, so it is left as it is and no checks were generated for Factory Droid. Run \`rulesync import --targets factorydroid --features checks\` to bring them into \`.rulesync/checks/\` and then delete the file, so the next generate writes it back from there; delete it outright if you no longer want it, or rename the directory if it is an ordinary skill rather than review guidelines. Importing alone leaves this file as it is, so it keeps blocking generation until it is gone. A rulesync skill named \`${FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME}\` is no longer generated here — Factory's reviewer reads this path, so the checks feature owns it — but a directory an older rulesync wrote can still be sitting there.`);
9601
- return [];
9602
- }
9603
- const fileContent = renderCheckFile(rulesyncChecks);
9604
- return [new FactorydroidCheck({
9605
- outputRoot,
9606
- relativeDirPath: paths.relativeDirPath,
9607
- relativeFilePath,
9608
- fileContent,
9609
- global
9610
- })];
9611
- }
9612
- static async fromFile({ outputRoot = process.cwd(), global = false }) {
9613
- const paths = FactorydroidCheck.getSettablePaths({ global });
9614
- const relativeFilePath = paths.relativeFilePath ?? "SKILL.md";
9615
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
9616
- return new FactorydroidCheck({
9617
- outputRoot,
9618
- relativeDirPath: paths.relativeDirPath,
9619
- relativeFilePath,
9620
- fileContent: await readFileContentOrNull(filePath) ?? "",
9621
- global
9622
- });
9623
- }
9624
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
9625
- return new FactorydroidCheck({
9626
- outputRoot,
9627
- relativeDirPath,
9628
- relativeFilePath,
9629
- fileContent: "",
9630
- validate: false,
9631
- global
9632
- });
9633
- }
9634
- validate() {
10331
+ static getAggregatedCheckConfig() {
9635
10332
  return {
9636
- success: true,
9637
- error: null
10333
+ displayName: "Factory Droid",
10334
+ toolTarget: "factorydroid",
10335
+ fallbackCheckName: FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME,
10336
+ handWrittenPreamble: "skip",
10337
+ handWrittenWarning,
10338
+ transformImportedContent: stripSkillFrontmatter
9638
10339
  };
9639
10340
  }
9640
- toRulesyncCheck() {
9641
- const first = this.toRulesyncChecks()[0];
9642
- if (!first) throw new Error(`No check instructions found in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
9643
- return first;
9644
- }
9645
- toRulesyncChecks() {
9646
- return splitCheckFile({
9647
- fileContent: stripSkillFrontmatter(this.getFileContent()),
9648
- fallbackName: FALLBACK_CHECK_NAME$1
9649
- });
9650
- }
9651
10341
  };
9652
10342
  //#endregion
9653
10343
  //#region src/constants/hermesagent-paths.ts
@@ -9906,7 +10596,6 @@ var HermesagentCheck = class HermesagentCheck extends ToolCheck {
9906
10596
  };
9907
10597
  //#endregion
9908
10598
  //#region src/features/checks/rovodev-check.ts
9909
- const FALLBACK_CHECK_NAME = "review-agent";
9910
10599
  /**
9911
10600
  * Checks adapter for Rovo Dev CLI's code-review custom instructions
9912
10601
  * (`.rovodev/.review-agent.md`).
@@ -9915,9 +10604,9 @@ const FALLBACK_CHECK_NAME = "review-agent";
9915
10604
  * `.rovodev/` folder — no frontmatter, and note the leading dot in the file
9916
10605
  * name. Like Cursor Bugbot it is a single aggregated file rather than a file
9917
10606
  * per check, so every `.rulesync/checks/*.md` targeting Rovo Dev collapses into
9918
- * it via {@link fromRulesyncChecks}, with each check written as a marked
9919
- * section (see `aggregated-check-file.ts` for the marker convention the two
9920
- * adapters share).
10607
+ * it via the `fromRulesyncChecks` on {@link AggregatedToolCheck}, with each
10608
+ * check written as a marked section (see `aggregated-check-file.ts` for the
10609
+ * marker convention the three aggregated adapters share).
9921
10610
  *
9922
10611
  * Rovo Dev reads the file as free prose, so a check's `severity` and `tools`
9923
10612
  * have no equivalent there: they are not written and do not come back on
@@ -9929,88 +10618,21 @@ const FALLBACK_CHECK_NAME = "review-agent";
9929
10618
  *
9930
10619
  * @see https://support.atlassian.com/rovo/docs/set-custom-instructions-for-code-reviews/
9931
10620
  */
9932
- var RovodevCheck = class RovodevCheck extends ToolCheck {
10621
+ var RovodevCheck = class extends AggregatedToolCheck {
9933
10622
  static getSettablePaths(_options = {}) {
9934
10623
  return {
9935
10624
  relativeDirPath: ROVODEV_DIR,
9936
10625
  relativeFilePath: ROVODEV_REVIEW_AGENT_FILE_NAME
9937
10626
  };
9938
10627
  }
9939
- static isTargetedByRulesyncCheck(rulesyncCheck) {
9940
- return this.isTargetedByRulesyncCheckDefault({
9941
- rulesyncCheck,
9942
- toolTarget: "rovodev"
9943
- });
9944
- }
9945
- /**
9946
- * Ownership guard the processor consults before it deletes anything for this
9947
- * tool. `.review-agent.md` is a file Rovo Dev's own documentation tells users
9948
- * to hand-write, so anything in it that rulesync did not write is not
9949
- * rulesync's to remove — dropping the last check targeting Rovo Dev must not
9950
- * take somebody's hand-written review instructions with it.
9951
- */
9952
- static async canDeleteAuxiliaryFiles({ outputRoot }) {
9953
- const paths = RovodevCheck.getSettablePaths();
9954
- const fileContent = await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath ?? ".review-agent.md"));
9955
- if (fileContent === null) return true;
9956
- return isOnlyGeneratedSections(fileContent);
9957
- }
9958
- static fromRulesyncCheck(_params) {
9959
- throw new Error("Rovo Dev checks are built from all checks at once; use fromRulesyncChecks.");
9960
- }
9961
- static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
9962
- if (rulesyncChecks.length === 0) return [];
9963
- const paths = RovodevCheck.getSettablePaths({ global });
9964
- const relativeFilePath = paths.relativeFilePath ?? ".review-agent.md";
9965
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
9966
- if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) logger?.warn(`Rovo Dev checks: ${filePath} holds instructions rulesync did not write, and generating replaces the whole file. Run \`rulesync import --targets rovodev --features checks\` first to keep them.`);
9967
- return [new RovodevCheck({
9968
- outputRoot,
9969
- relativeDirPath: paths.relativeDirPath,
9970
- relativeFilePath,
9971
- fileContent: renderCheckFile(rulesyncChecks),
9972
- global
9973
- })];
9974
- }
9975
- static async fromFile({ outputRoot = process.cwd(), global = false }) {
9976
- const paths = RovodevCheck.getSettablePaths({ global });
9977
- const relativeFilePath = paths.relativeFilePath ?? ".review-agent.md";
9978
- const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
9979
- return new RovodevCheck({
9980
- outputRoot,
9981
- relativeDirPath: paths.relativeDirPath,
9982
- relativeFilePath,
9983
- fileContent: await readFileContentOrNull(filePath) ?? "",
9984
- global
9985
- });
9986
- }
9987
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
9988
- return new RovodevCheck({
9989
- outputRoot,
9990
- relativeDirPath,
9991
- relativeFilePath,
9992
- fileContent: "",
9993
- validate: false,
9994
- global
9995
- });
9996
- }
9997
- validate() {
10628
+ static getAggregatedCheckConfig() {
9998
10629
  return {
9999
- success: true,
10000
- error: null
10630
+ displayName: "Rovo Dev",
10631
+ toolTarget: "rovodev",
10632
+ fallbackCheckName: "review-agent",
10633
+ handWrittenPreamble: "replace"
10001
10634
  };
10002
10635
  }
10003
- toRulesyncCheck() {
10004
- const first = this.toRulesyncChecks()[0];
10005
- if (!first) throw new Error(`No check instructions found in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
10006
- return first;
10007
- }
10008
- toRulesyncChecks() {
10009
- return splitCheckFile({
10010
- fileContent: this.getFileContent(),
10011
- fallbackName: FALLBACK_CHECK_NAME
10012
- });
10013
- }
10014
10636
  };
10015
10637
  //#endregion
10016
10638
  //#region src/constants/codexcli-paths.ts
@@ -11218,7 +11840,7 @@ var ChecksProcessor = class extends FeatureProcessor {
11218
11840
  this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
11219
11841
  return [];
11220
11842
  }
11221
- const mdFiles = (await listDirectoryFiles(checksDir)).filter((file) => file.endsWith(".md"));
11843
+ const mdFiles = (await listDirectoryEntryNames(checksDir)).filter((file) => file.endsWith(".md"));
11222
11844
  if (mdFiles.length === 0) {
11223
11845
  this.logger.debug(`No markdown files found in rulesync checks directory: ${checksDir}`);
11224
11846
  return [];
@@ -16040,6 +16662,7 @@ const ZCODE_SKILLS_DIR_PATH = (0, node_path.join)(ZCODE_DIR, "skills");
16040
16662
  const ZCODE_CONFIG_FILE_NAME = "config.json";
16041
16663
  const ZCODE_GLOBAL_CONFIG_DIR_PATH = (0, node_path.join)(ZCODE_DIR, "cli");
16042
16664
  const ZCODE_MCP_SERVERS_KEY = "servers";
16665
+ const ZCODE_AGENTS_DIR_PATH = (0, node_path.join)(ZCODE_DIR, "agents");
16043
16666
  //#endregion
16044
16667
  //#region src/features/commands/zcode-command.ts
16045
16668
  /**
@@ -17002,6 +17625,52 @@ function compact(obj) {
17002
17625
  return result;
17003
17626
  }
17004
17627
  //#endregion
17628
+ //#region src/utils/quote-value.ts
17629
+ /**
17630
+ * How much of a value read off disk a diagnostic quotes.
17631
+ *
17632
+ * Enough to recognize which entry is meant, and no more. A warning names the
17633
+ * offending value so the reader can find it, but the values these warnings
17634
+ * quote come from files rulesync did not write — a tool's own settings, a
17635
+ * machine-local overrides file, a repository fetched from elsewhere — and they
17636
+ * no longer stop at a terminal: they travel into a `--json` document another
17637
+ * program parses and into an MCP result an agent reads as context. A command
17638
+ * line or a header is the shape most likely to carry a credential, and a long
17639
+ * value is the shape most likely to carry instructions aimed at the agent.
17640
+ */
17641
+ const MAX_QUOTED_VALUE_LENGTH = 60;
17642
+ /**
17643
+ * A short, quotable rendering of a value for a diagnostic.
17644
+ *
17645
+ * Serialized rather than interpolated, because an unquoted value is what lets a
17646
+ * crafted one read as a second line; stripped of the control characters
17647
+ * `JSON.stringify` leaves intact (it escapes C0 only, not the C1 range or the
17648
+ * bidirectional overrides); and truncated.
17649
+ */
17650
+ function quoteValueForWarning(value) {
17651
+ return truncateText({
17652
+ text: stripControlCharacters(serialize(value)),
17653
+ maxLength: MAX_QUOTED_VALUE_LENGTH,
17654
+ suffix: "…(truncated)"
17655
+ });
17656
+ }
17657
+ function serialize(value) {
17658
+ try {
17659
+ return JSON.stringify(value, stripStrings) ?? String(value);
17660
+ } catch {
17661
+ return `[unserializable ${typeof value}]`;
17662
+ }
17663
+ }
17664
+ /**
17665
+ * Strip the control characters out of every string before `JSON.stringify`
17666
+ * sees it, not only out of the document it produces: `JSON.stringify` escapes
17667
+ * a C0 character into the six literal characters `\u001b`, which no later pass
17668
+ * over the output can recognize as a control character again.
17669
+ */
17670
+ function stripStrings(_key, value) {
17671
+ return typeof value === "string" ? stripControlCharacters(value) : value;
17672
+ }
17673
+ //#endregion
17005
17674
  //#region src/features/hooks/tool-hooks-converter.ts
17006
17675
  function isToolMatcherEntry(x) {
17007
17676
  if (x === null || typeof x !== "object") return false;
@@ -17103,7 +17772,7 @@ function emitPassthroughFields({ def, hookType, eventName, fields, isValid, warn
17103
17772
  if (!isValid({
17104
17773
  value,
17105
17774
  canonical
17106
- })) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${JSON.stringify(value)} is not a value this tool can express as "${tool}".`);
17775
+ })) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${quoteValueForWarning(value)} is not a value this tool can express as "${tool}".`);
17107
17776
  }
17108
17777
  return Object.fromEntries(fields.filter(({ canonical, commandOnly }) => isFieldApplicable({
17109
17778
  commandOnly,
@@ -17198,7 +17867,7 @@ function describeScalarConstraint({ canonical, value }) {
17198
17867
  if (issue === void 0) return `it is not a value the canonical "${canonical}" field accepts.`;
17199
17868
  return `it does not satisfy the canonical "${canonical}" field: ${issue.message}.`;
17200
17869
  }
17201
- const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${JSON.stringify(value)}) while importing a hook: ${describeScalarConstraint({
17870
+ const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${quoteValueForWarning(value)}) while importing a hook: ${describeScalarConstraint({
17202
17871
  canonical,
17203
17872
  value
17204
17873
  })} Importing it would fail validation on the next run.`;
@@ -17225,7 +17894,7 @@ function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }
17225
17894
  if (first === void 0) continue;
17226
17895
  const firstStable = stableJson(first);
17227
17896
  const agrees = (value) => isGroupPassthroughValue(value, valueType) && stableJson(value) === firstStable;
17228
- if (!carried.every(agrees)) logger?.warn(`"${tool}" belongs to the whole matcher group on "${eventName}" hooks, so every hook in this group gets ${JSON.stringify(first)} — including any that asked for something else, or for nothing.`);
17897
+ if (!carried.every(agrees)) logger?.warn(`"${tool}" belongs to the whole matcher group on "${eventName}" hooks, so every hook in this group gets ${quoteValueForWarning(first)} — including any that asked for something else, or for nothing.`);
17229
17898
  emitted[tool] = first;
17230
17899
  }
17231
17900
  return emitted;
@@ -17712,7 +18381,7 @@ function describeGroupSkipReason({ rawEntry, converterConfig }) {
17712
18381
  for (const { tool, valueType, subdividesGroup } of converterConfig.groupPassthroughFields ?? []) {
17713
18382
  const value = entry[tool];
17714
18383
  if (subdividesGroup !== true || value === void 0) continue;
17715
- if (!isGroupPassthroughValue(value, valueType)) return `Skipping the hooks of a matcher group while importing: its "${tool}" (${JSON.stringify(value)}) is unusable, and these hooks run only where it matches. Importing them without it would widen when they fire, so they are skipped.`;
18384
+ if (!isGroupPassthroughValue(value, valueType)) return `Skipping the hooks of a matcher group while importing: its "${tool}" (${quoteValueForWarning(value)}) is unusable, and these hooks run only where it matches. Importing them without it would widen when they fire, so they are skipped.`;
17716
18385
  }
17717
18386
  }
17718
18387
  /**
@@ -17729,7 +18398,7 @@ function describeHookSkipReason({ h, rawEntry, hookType, converterConfig }) {
17729
18398
  value,
17730
18399
  canonical: field
17731
18400
  })) continue;
17732
- return `Skipping a hook while importing: its "${field}" (${JSON.stringify(value)}) is unusable — ${describeScalarConstraint({
18401
+ return `Skipping a hook while importing: its "${field}" (${quoteValueForWarning(value)}) is unusable — ${describeScalarConstraint({
17733
18402
  canonical: field,
17734
18403
  value
17735
18404
  })} Keeping the hook without it would change what it does, so the whole hook is skipped.`;
@@ -18051,7 +18720,22 @@ async function readSettingsWithLocalOverlay({ outputRoot, relativeDirPath, baseF
18051
18720
  }
18052
18721
  /** Quotes a name read off disk, the way every other such name is logged. */
18053
18722
  function quoteKey(key) {
18054
- return JSON.stringify(stripControlCharacters(key));
18723
+ return quoteValueForWarning(key);
18724
+ }
18725
+ /**
18726
+ * How many keys the warning names before it stops counting.
18727
+ *
18728
+ * The keys come from a file rulesync did not write, and the warning now travels
18729
+ * into `--json` documents and MCP results as well as onto a console. A settings
18730
+ * file with hundreds of top-level keys is unusual but not impossible, and the
18731
+ * point of the sentence is to make the reader open the file — naming the first
18732
+ * few does that as well as naming all of them.
18733
+ */
18734
+ const MAX_LISTED_KEYS = 20;
18735
+ function listKeys(keys) {
18736
+ const named = keys.slice(0, MAX_LISTED_KEYS).map(quoteKey).join(", ");
18737
+ const rest = keys.length - MAX_LISTED_KEYS;
18738
+ return rest > 0 ? `${named} and ${rest} more` : named;
18055
18739
  }
18056
18740
  /**
18057
18741
  * Name the settings the machine-local file contributed, so nobody publishes one
@@ -18071,8 +18755,8 @@ function warnAboutLocalKeys({ localParsed, configPath, toolLabel, sensitiveKeys,
18071
18755
  const keys = Object.keys(localParsed);
18072
18756
  if (keys.length === 0) return;
18073
18757
  const flagged = keys.filter((key) => sensitiveKeys.includes(key));
18074
- const guardrailSentence = flagged.length === 0 ? "" : ` ${flagged.map(quoteKey).join(", ")} ${flagged.length === 1 ? "decides" : "decide"} what ${toolLabel} is allowed to do, so a value meant for one machine would become the team's guardrail.`;
18075
- warnOnceWithFallback(logger, `${toolLabel}: ${configPath} is a machine-local overrides file, and importing read ${keys.map(quoteKey).join(", ")} from it. Whatever an import takes from there lands in files rulesync commits, so check the imported files and remove anything personal to this machine before sharing them.${guardrailSentence}`);
18758
+ const guardrailSentence = flagged.length === 0 ? "" : ` ${listKeys(flagged)} ${flagged.length === 1 ? "decides" : "decide"} what ${toolLabel} is allowed to do, so a value meant for one machine would become the team's guardrail.`;
18759
+ warnOnceWithFallback(logger, `${toolLabel}: ${configPath} is a machine-local overrides file, and importing read ${listKeys(keys)} from it. Whatever an import takes from there lands in files rulesync commits, so check the imported files and remove anything personal to this machine before sharing them.${guardrailSentence}`);
18076
18760
  }
18077
18761
  //#endregion
18078
18762
  //#region src/utils/augmentcode-settings.ts
@@ -22767,6 +23451,9 @@ function withToolTargetPrefix({ logger, toolTarget }) {
22767
23451
  get silent() {
22768
23452
  return logger.silent;
22769
23453
  },
23454
+ get reportsWhileSilent() {
23455
+ return logger.reportsWhileSilent;
23456
+ },
22770
23457
  get jsonMode() {
22771
23458
  return logger.jsonMode;
22772
23459
  },
@@ -28881,7 +29568,7 @@ function convertFromMusecodeFormat(musecodeMcp) {
28881
29568
  if (key === "mode") {
28882
29569
  const mode = asMusecodeMode(value);
28883
29570
  if (mode === void 0) {
28884
- warnWithFallback(void 0, `Muse Code MCP: dropping mode ${JSON.stringify(value)} on server ${JSON.stringify(name)} because it is neither "required" nor "optional", the only two modes Muse Code documents.`);
29571
+ warnWithFallback(void 0, `Muse Code MCP: dropping mode ${quoteValueForWarning(value)} on server ${quoteValueForWarning(name)} because it is neither "required" nor "optional", the only two modes Muse Code documents.`);
28885
29572
  continue;
28886
29573
  }
28887
29574
  converted.musecodeMode = mode;
@@ -30085,7 +30772,7 @@ function pointerLabels(global) {
30085
30772
  async function warnAtDocumentedDefault({ existing, outputRoot, logger }) {
30086
30773
  const { pointer, configLabel, mcpLabel } = pointerLabels(true);
30087
30774
  const displaced = await describeDisplacedGlobalServers({ outputRoot });
30088
- logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${JSON.stringify(existing)} in ${configLabel}. That is the default Atlassian's settings reference documents, so it may be Rovo Dev's own value rather than one you chose — and while it stands, the generated ${mcpLabel} is never read. ` + (displaced === null ? `It defines no servers of its own, so setting mcp.mcpConfigPath to "${pointer}" costs nothing.` : `${displaced} — move the servers you want to keep into .rulesync/mcp.jsonc first, or none at all if it turns out to hold nothing you need, then change mcp.mcpConfigPath to "${pointer}".`));
30775
+ logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${quoteValueForWarning(existing)} in ${configLabel}. That is the default Atlassian's settings reference documents, so it may be Rovo Dev's own value rather than one you chose — and while it stands, the generated ${mcpLabel} is never read. ` + (displaced === null ? `It defines no servers of its own, so setting mcp.mcpConfigPath to "${pointer}" costs nothing.` : `${displaced} — move the servers you want to keep into .rulesync/mcp.jsonc first, or none at all if it turns out to hold nothing you need, then change mcp.mcpConfigPath to "${pointer}".`));
30089
30776
  }
30090
30777
  /**
30091
30778
  * Announce a pointer that was just written. Warned rather than noted in global
@@ -30142,10 +30829,10 @@ async function applyMcpConfigPointer({ existingMcp, global, hasLiveServers, outp
30142
30829
  return false;
30143
30830
  }
30144
30831
  if (global && normalizedExisting !== void 0 && envVarMcpFileSpellings({ fileName: "mcp.json" }).includes(normalizedExisting)) {
30145
- logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} is ${JSON.stringify(existing)}. That names ${mcpLabel} only if Rovo Dev expands environment variables in this setting, which Atlassian does not document — if it does not, the path resolves literally and Rovo Dev reads no MCP servers at all. Write "${pointer}" instead, the form its own documented default uses.`);
30832
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} is ${quoteValueForWarning(existing)}. That names ${mcpLabel} only if Rovo Dev expands environment variables in this setting, which Atlassian does not document — if it does not, the path resolves literally and Rovo Dev reads no MCP servers at all. Write "${pointer}" instead, the form its own documented default uses.`);
30146
30833
  return false;
30147
30834
  }
30148
- logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${JSON.stringify(existing)} in ${configLabel}. Rovo Dev reads MCP servers from that path, so the generated ${mcpLabel} is unused until it is set to "${pointer}".`);
30835
+ logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${quoteValueForWarning(existing)} in ${configLabel}. Rovo Dev reads MCP servers from that path, so the generated ${mcpLabel} is unused until it is set to "${pointer}".`);
30149
30836
  return false;
30150
30837
  }
30151
30838
  /**
@@ -31765,11 +32452,10 @@ var ToolPermissions = class extends ToolFile {
31765
32452
  throw new Error("Please implement this method in the subclass.");
31766
32453
  }
31767
32454
  toRulesyncPermissionsDefault({ fileContent }) {
31768
- return new RulesyncPermissions({
32455
+ return RulesyncPermissions.fromImportedFileContent({
31769
32456
  outputRoot: this.outputRoot,
31770
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
31771
- relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
31772
- fileContent: withoutBlankPermissionPatterns({ fileContent })
32457
+ fileContent,
32458
+ sourcePath: this.getRelativePathFromCwd()
31773
32459
  });
31774
32460
  }
31775
32461
  static async fromFile(_params) {
@@ -35527,7 +36213,7 @@ function asCursorPermissionEntryArray(value, logger, fieldLabel) {
35527
36213
  if (!Array.isArray(value)) return [];
35528
36214
  const result = [];
35529
36215
  for (const item of value) if (typeof item === "string") result.push(item);
35530
- else logger?.warn(`Cursor CLI permissions${fieldLabel ? `.${fieldLabel}` : ""} contains a non-string entry; dropping ${JSON.stringify(item)}.`);
36216
+ else logger?.warn(`Cursor CLI permissions${fieldLabel ? `.${fieldLabel}` : ""} contains a non-string entry; dropping ${quoteValueForWarning(item)}.`);
35531
36217
  return result;
35532
36218
  }
35533
36219
  /**
@@ -37381,7 +38067,10 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
37381
38067
  fileContent: this.getFileContent()
37382
38068
  });
37383
38069
  const rawProvenance = (isRecord$1(config.permissions) ? config.permissions : {}).rulesync;
37384
- const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(isRecord$1(rawProvenance) ? withoutBlankPermissionPatternsIn({ config: rawProvenance }) : rawProvenance);
38070
+ const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(isRecord$1(rawProvenance) ? withoutBlankPermissionKeysIn({
38071
+ config: rawProvenance,
38072
+ sourcePath: this.getRelativePathFromCwd()
38073
+ }) : rawProvenance);
37385
38074
  const provenance = parsedProvenance.success ? parsedProvenance.data : { permission: {} };
37386
38075
  const permission = clonePermissionBlock(provenance.permission);
37387
38076
  reconcileCommandAllowlist({
@@ -37409,14 +38098,13 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
37409
38098
  permission,
37410
38099
  ...Object.keys(hermes).length > 0 && { hermes }
37411
38100
  };
37412
- return new RulesyncPermissions({
38101
+ return RulesyncPermissions.fromImportedFileContent({
37413
38102
  outputRoot: getHermesagentRulesyncOutputRoot({
37414
38103
  nativeOutputRoot: this.outputRoot,
37415
38104
  global: this.global
37416
38105
  }),
37417
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
37418
- relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
37419
- fileContent: withoutBlankPermissionPatterns({ fileContent: JSON.stringify(imported, null, 2) })
38106
+ sourcePath: this.getRelativePathFromCwd(),
38107
+ fileContent: JSON.stringify(imported, null, 2)
37420
38108
  });
37421
38109
  }
37422
38110
  static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false }) {
@@ -38317,17 +39005,16 @@ var KimiCodePermissions = class KimiCodePermissions extends ToolPermissions {
38317
39005
  ...nativeRules.length > 0 && { rules: nativeRules },
38318
39006
  ...tools && { tools }
38319
39007
  };
38320
- return new RulesyncPermissions({
39008
+ return RulesyncPermissions.fromImportedFileContent({
38321
39009
  outputRoot: getKimiCodeRulesyncOutputRoot({
38322
39010
  nativeOutputRoot: this.outputRoot,
38323
39011
  global: this.global
38324
39012
  }),
38325
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
38326
- relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
38327
- fileContent: withoutBlankPermissionPatterns({ fileContent: JSON.stringify({
39013
+ sourcePath: this.getRelativePathFromCwd(),
39014
+ fileContent: JSON.stringify({
38328
39015
  permission,
38329
39016
  ...Object.keys(toolOverride).length > 0 && { "kimi-code": toolOverride }
38330
- }, null, 2) })
39017
+ }, null, 2)
38331
39018
  });
38332
39019
  }
38333
39020
  static forDeletion({ outputRoot = process.cwd() }) {
@@ -40901,6 +41588,28 @@ function pickSecurityPolicies(source, report) {
40901
41588
  * Ambiguous-width characters are counted as one column, which is what a
40902
41589
  * terminal running a Latin font does.
40903
41590
  *
41591
+ * Two of the ranges are here for a narrower reason: a name the skill prompt can
41592
+ * offer may not be counted narrower here than the prompt's own renderer counts
41593
+ * it, or a label that fits the budget wraps anyway and paints the second row
41594
+ * the budget exists to prevent. `@inquirer/core` measures with
41595
+ * `fast-string-width`, which takes the whole of `Script=Hangul` as wide and
41596
+ * every `Emoji_Modifier_Base` as an emoji. So the Hangul jamo are taken to
41597
+ * U+11FF rather than stopping at the leading consonants, and the modifier bases
41598
+ * are named beside the emoji: U+261D, U+26F9 and the two hands of U+270C–U+270D
41599
+ * are `Emoji` without being `Emoji_Presentation`, and were the only characters
41600
+ * outside Hangul this counted at one column while the renderer counted two.
41601
+ *
41602
+ * The rule is over the names that can reach the prompt, which is a smaller set
41603
+ * than the characters that exist. The renderer counts a tab at eight columns and
41604
+ * the Hangul fillers at two, where this counts one and none: a name carrying
41605
+ * either is refused outright by `hasDeceptiveHiddenCharacters` — the tab as a
41606
+ * control character, the fillers as characters that draw as nothing — and never
41607
+ * becomes a row to be measured. The two joiners are the invisible characters
41608
+ * that check lets through, so they are counted below rather than left to the
41609
+ * zero-width rule. Where this is used to lay out text of the tool's own rather
41610
+ * than to bound an untrusted name, the difference is a column of alignment and
41611
+ * not a forged row.
41612
+ *
40904
41613
  * The wide planes are taken whole rather than range by range — Tangut, Khitan
40905
41614
  * and Nushu together are U+17000–U+18DFF, and the kana supplements are
40906
41615
  * U+1AFF0–U+1B2FF — because a gap between two of them is exactly the character
@@ -40913,7 +41622,7 @@ function pickSecurityPolicies(source, report) {
40913
41622
  * is canonically the ordinary ideograph U+8C48, and a range that starts there
40914
41623
  * instead silently swallows thirty thousand code points that are not wide.
40915
41624
  */
40916
- const WIDE_CHARACTERS_PATTERN = /\p{Emoji_Presentation}|[\u2329\u232a\u2630-\u2637\u268a-\u268f\u4dc0-\u4dff]|[\u1100-\u115f\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;
41625
+ 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;
40917
41626
  /**
40918
41627
  * U+FE0F VARIATION SELECTOR-16, which takes no width of its own but asks the
40919
41628
  * character before it to be drawn as an emoji — that is, in two columns rather
@@ -40927,6 +41636,27 @@ const COMBINING_MARK_PATTERN = /[\p{Mn}\p{Me}]/u;
40927
41636
  /** The characters that take no width at all, marks aside. */
40928
41637
  const ZERO_WIDTH_CHARACTERS_PATTERN = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
40929
41638
  /**
41639
+ * The zero-width joiner and its non-joining twin, which the renderer spends a
41640
+ * column on apiece.
41641
+ *
41642
+ * A terminal draws neither, and every other character that draws as nothing is
41643
+ * counted at nothing here. These two are the exception because they are the
41644
+ * only invisible characters `hasDeceptiveHiddenCharacters` lets through — a
41645
+ * Persian or Indic name spells a word with one, and an emoji is a chain of them
41646
+ * — so they are the only ones an attacker can put in a name that reaches the
41647
+ * prompt. `fast-string-width`, which is where the prompt's own wrapping is
41648
+ * decided, counts each of them as a column, and 40 of them in a name is 40
41649
+ * columns of budget this would otherwise hand over for free: enough for a name
41650
+ * measured at 39 columns to be drawn at 77 and wrap a forged row underneath
41651
+ * itself.
41652
+ *
41653
+ * The cost is that an emoji built from a chain is overstated by a column per
41654
+ * joiner, on top of the two columns per component it is already overstated by.
41655
+ * Overstating shortens a label that did not need it; understating lets one
41656
+ * wrap.
41657
+ */
41658
+ const RENDERER_COUNTED_JOINERS = /\u200c|\u200d/u;
41659
+ /**
40930
41660
  * How many marks a single character is allowed to carry for free.
40931
41661
  *
40932
41662
  * A written language stacks two or three at most — a Devanagari vowel sign and
@@ -40954,6 +41684,7 @@ function widthInContext(params) {
40954
41684
  const { character, precedingMarks } = params;
40955
41685
  if (character === EMOJI_PRESENTATION_SELECTOR) return 1;
40956
41686
  if (isCombiningMark(character)) return precedingMarks < FREE_MARKS_PER_CHARACTER ? 0 : 1;
41687
+ if (RENDERER_COUNTED_JOINERS.test(character)) return 1;
40957
41688
  if (ZERO_WIDTH_CHARACTERS_PATTERN.test(character)) return 0;
40958
41689
  return WIDE_CHARACTERS_PATTERN.test(character) ? 2 : 1;
40959
41690
  }
@@ -40976,6 +41707,8 @@ function displayWidthOf(text) {
40976
41707
  }
40977
41708
  return width;
40978
41709
  }
41710
+ /** The mark a cut string ends in. */
41711
+ const SHORTENING_ELLIPSIS = "…";
40979
41712
  /**
40980
41713
  * Cut `text` down to at most `budget` columns, marking the cut with an ellipsis.
40981
41714
  *
@@ -41001,7 +41734,7 @@ function shortenToWidth(params) {
41001
41734
  width += characterWidth;
41002
41735
  marks = isCombiningMark(character) ? marks + 1 : 0;
41003
41736
  }
41004
- return `${kept.join("")}…`;
41737
+ return `${kept.join("")}${SHORTENING_ELLIPSIS}`;
41005
41738
  }
41006
41739
  //#endregion
41007
41740
  //#region src/features/permissions/vibe-permissions.ts
@@ -44789,6 +45522,83 @@ const NESTED_SCAN_EXCLUDED_ROOT_DIRS = [
44789
45522
  ];
44790
45523
  //#endregion
44791
45524
  //#region src/features/skills/claudecode-skill.ts
45525
+ /**
45526
+ * The `.claude/skills` tail every scanned root is expected to end with, written
45527
+ * posix-separated so it can be compared against a resolved relative path, and
45528
+ * split into its segments so what sits above it can be taken apart.
45529
+ *
45530
+ * Split here rather than through the shared helper: this is evaluated as the
45531
+ * module loads, before a test that mocks the file utilities can supply one.
45532
+ */
45533
+ const CLAUDECODE_SKILLS_DIR_SEGMENTS = CLAUDECODE_SKILLS_DIR_PATH.split(node_path.sep);
45534
+ const CLAUDECODE_SKILLS_DIR_POSIX_PATH = CLAUDECODE_SKILLS_DIR_SEGMENTS.join("/");
45535
+ /**
45536
+ * The segment that puts `relativeDirPath` inside a tree the nested scan
45537
+ * excludes, or `undefined` when none does. These are the same three rules the
45538
+ * scan states as glob `ignore` patterns below -- dependency trees at any depth,
45539
+ * build and vendoring directories at the project root, hidden directories other
45540
+ * than the `.claude` being matched -- and the two have to be kept in step.
45541
+ *
45542
+ * Saying them twice is what a second pass costs. globby matches its patterns
45543
+ * against the path it reports, before the `..` a rewritten directory name
45544
+ * carries is folded away and before a link in the path is resolved. A root
45545
+ * reported at `x/../node_modules/.claude/skills` matches none of the patterns
45546
+ * and then leads to the dependency tree they name, so the decision has to be
45547
+ * taken again on the path that is really read.
45548
+ *
45549
+ * The segments passed are the ones above the `.claude/skills` tail, taken from
45550
+ * the resolved path: a directory name may hold a backslash -- the whole reason a
45551
+ * path can arrive here misspelled -- so the split that produced them has to be on
45552
+ * `/`, the one separator no name can contain. The tail itself is the part the
45553
+ * glob matched and is not judged; `.claude` is hidden by definition and every
45554
+ * root the scan reports ends with it.
45555
+ */
45556
+ function excludedNestedScanSegment(segments) {
45557
+ return segments.find((segment, index) => NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.includes(segment) || index === 0 && NESTED_SCAN_EXCLUDED_ROOT_DIRS.includes(segment) || isHiddenPathSegment(segment));
45558
+ }
45559
+ /**
45560
+ * Whether a nested skills directory the scan reported can be used as an import
45561
+ * root: either the reason it cannot, or the path it resolves to relative to the
45562
+ * project, which the caller uses to tell two spellings of one root apart.
45563
+ *
45564
+ * A recursive glob cannot be swapped for a walk the way a flat one can, so the
45565
+ * path it hands back is checked instead. globby reads a backslash as a path
45566
+ * separator and rewrites it, so a root below a directory really named
45567
+ * `back\\slash` is reported at `back/slash`. Where that leads decides what to
45568
+ * do with it, and the spelling alone does not say: `back/slash` usually answers
45569
+ * to nothing, but `x\\..\\..\\outside` is reported at `x/../../outside`, which
45570
+ * climbs out of the project through the real sibling `x/`, and `a\\b` at `a/b`,
45571
+ * which may be a symbolic link out of the project that the scan — it passes
45572
+ * `followSymbolicLinks: false` — never meant to reach. Both are refused by
45573
+ * resolving the path rather than reading it.
45574
+ *
45575
+ * What is deliberately not refused is a rewritten path that stays inside the
45576
+ * project, such as `x\\..\\y` reported at `x/../y`. It names a real directory
45577
+ * `y`, and the scan reports that directory under this spelling *instead of* its
45578
+ * own, so refusing it would lose `y`'s skills rather than protect anything. The
45579
+ * skills under the directory that was really named are unreachable either way:
45580
+ * no path the scan can report leads back to a name holding a backslash.
45581
+ *
45582
+ * That last shape is the one case the scan cannot warn about. `a\\b` reported
45583
+ * at `a/b`, where `a/b` is itself a real directory, is indistinguishable from
45584
+ * the ordinary root `a/b` -- both are spelled the same and both are there -- so
45585
+ * the skills under `a\\b` are dropped without a word. Nothing in the path says
45586
+ * a second directory was ever involved.
45587
+ */
45588
+ async function checkNestedSkillsRoot({ outputRoot, dirPath }) {
45589
+ if (!await directoryExists(dirPath)) return { reason: "it could not be read under the path the scan reports, most often because a directory name above it contains a backslash." };
45590
+ const realRelativeDirPath = await resolvedRelativePath({
45591
+ rootPath: outputRoot,
45592
+ targetPath: dirPath
45593
+ });
45594
+ if (posixRelativePathEscapesRoot(realRelativeDirPath)) return { reason: "it resolves outside the project." };
45595
+ const segments = realRelativeDirPath.split("/");
45596
+ const aboveTailSegments = segments.slice(0, -CLAUDECODE_SKILLS_DIR_SEGMENTS.length);
45597
+ 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.` };
45598
+ const excludedSegment = excludedNestedScanSegment(aboveTailSegments);
45599
+ if (excludedSegment !== void 0) return { reason: `it resolves inside ${JSON.stringify(stripControlCharacters(excludedSegment))}, which the nested scan excludes.` };
45600
+ return { realRelativeDirPath };
45601
+ }
44792
45602
  const ClaudecodeSkillFrontmatterSchema = zod_mini.z.looseObject({
44793
45603
  name: zod_mini.z.string(),
44794
45604
  description: zod_mini.z.string(),
@@ -45037,10 +45847,10 @@ var ClaudecodeSkill = class extends ToolSkill {
45037
45847
  *
45038
45848
  * @see https://code.claude.com/docs/en/skills
45039
45849
  */
45040
- static async getConfiguredImportRoots({ outputRoot, global = false }) {
45850
+ static async getConfiguredImportRoots({ outputRoot, global = false, logger }) {
45041
45851
  if (global) return [];
45042
45852
  const root = toPosixPath(outputRoot);
45043
- return filterOutPathsInGitIgnoredDirectories({
45853
+ const filteredDirPaths = filterOutPathsInGitIgnoredDirectories({
45044
45854
  rootDir: outputRoot,
45045
45855
  filePaths: await findFilesByGlobs([`${root}/*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`], {
45046
45856
  type: "dir",
@@ -45051,10 +45861,27 @@ var ClaudecodeSkill = class extends ToolSkill {
45051
45861
  ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
45052
45862
  ]
45053
45863
  })
45054
- }).toSorted().map((dirPath) => ({
45055
- outputRoot,
45056
- relativeDirPath: (0, node_path.relative)(outputRoot, dirPath)
45057
- }));
45864
+ }).toSorted();
45865
+ const roots = [];
45866
+ const seenRealRelativeDirPaths = /* @__PURE__ */ new Set([CLAUDECODE_SKILLS_DIR_POSIX_PATH]);
45867
+ for (const dirPath of filteredDirPaths) {
45868
+ const scannedDirPath = (0, node_path.resolve)(dirPath);
45869
+ const check = await checkNestedSkillsRoot({
45870
+ outputRoot,
45871
+ dirPath: scannedDirPath
45872
+ });
45873
+ if ("reason" in check) {
45874
+ logger?.warn(`Skipping the nested Claude Code skills directory ${JSON.stringify(stripControlCharacters(scannedDirPath))}: ${check.reason} Its skills are not imported.`);
45875
+ continue;
45876
+ }
45877
+ if (seenRealRelativeDirPaths.has(check.realRelativeDirPath)) continue;
45878
+ seenRealRelativeDirPaths.add(check.realRelativeDirPath);
45879
+ roots.push({
45880
+ outputRoot,
45881
+ relativeDirPath: (0, node_path.relative)(outputRoot, scannedDirPath)
45882
+ });
45883
+ }
45884
+ return roots;
45058
45885
  }
45059
45886
  getFrontmatter() {
45060
45887
  return ClaudecodeSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
@@ -49941,7 +50768,8 @@ var SkillsProcessor = class extends DirFeatureProcessor {
49941
50768
  const paths = factory.class.getSettablePaths({ global: this.global });
49942
50769
  const configuredRoots = factory.class.getConfiguredImportRoots ? await factory.class.getConfiguredImportRoots({
49943
50770
  outputRoot: this.outputRoot,
49944
- global: this.global
50771
+ global: this.global,
50772
+ logger: this.logger
49945
50773
  }) : [];
49946
50774
  const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
49947
50775
  const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
@@ -54657,6 +55485,152 @@ var VibeSubagent = class VibeSubagent extends ToolSubagent {
54657
55485
  }
54658
55486
  };
54659
55487
  //#endregion
55488
+ //#region src/features/subagents/zcode-subagent.ts
55489
+ const ZcodeSubagentFrontmatterSchema = zod_mini.z.looseObject({
55490
+ name: zod_mini.z.string(),
55491
+ description: zod_mini.z.optional(zod_mini.z.string()),
55492
+ model: zod_mini.z.optional(zod_mini.z.string()),
55493
+ thoughtLevel: zod_mini.z.optional(zod_mini.z.string()),
55494
+ color: zod_mini.z.optional(zod_mini.z.string()),
55495
+ tools: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
55496
+ disallowedTools: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
55497
+ maxTurns: zod_mini.z.optional(zod_mini.z.number().check(zod_mini.z.int(), zod_mini.z.positive())),
55498
+ injectAgentsMd: zod_mini.z.optional(zod_mini.z.boolean()),
55499
+ mcpServers: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
55500
+ });
55501
+ /**
55502
+ * ZCode subagents.
55503
+ *
55504
+ * Each subagent is one Markdown file with YAML frontmatter, named after the
55505
+ * agent, under `~/.zcode/agents/`.
55506
+ *
55507
+ * Global scope only. The current Beta "manages global / user-level subagents
55508
+ * stored under `~/.zcode/agents/`", and creating or editing workspace /
55509
+ * project-level subagents "is not available yet" — so this adapter is
55510
+ * registered with `supportsProject: false` and never writes into a project's
55511
+ * own `.zcode/`. The relative path is nonetheless spelled against
55512
+ * {@link ZCODE_AGENTS_DIR_PATH} so the workspace scope needs nothing more than
55513
+ * flipping that flag if ZCode ships it.
55514
+ *
55515
+ * @see https://zcode.z.ai/en/docs/subagents
55516
+ */
55517
+ var ZcodeSubagent = class ZcodeSubagent extends ToolSubagent {
55518
+ frontmatter;
55519
+ body;
55520
+ constructor({ frontmatter, body, fileContent, ...rest }) {
55521
+ if (rest.validate !== false) {
55522
+ const result = ZcodeSubagentFrontmatterSchema.safeParse(frontmatter);
55523
+ if (!result.success) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
55524
+ }
55525
+ super({
55526
+ ...rest,
55527
+ fileContent: fileContent ?? stringifyFrontmatter(body, frontmatter)
55528
+ });
55529
+ this.frontmatter = frontmatter;
55530
+ this.body = body;
55531
+ }
55532
+ static getSettablePaths(_options = {}) {
55533
+ return { relativeDirPath: ZCODE_AGENTS_DIR_PATH };
55534
+ }
55535
+ getFrontmatter() {
55536
+ return this.frontmatter;
55537
+ }
55538
+ getBody() {
55539
+ return this.body;
55540
+ }
55541
+ toRulesyncSubagent() {
55542
+ const { name, description, ...rest } = this.frontmatter;
55543
+ return new RulesyncSubagent({
55544
+ outputRoot: ".",
55545
+ frontmatter: {
55546
+ targets: ["*"],
55547
+ name,
55548
+ description,
55549
+ ...Object.keys(rest).length > 0 && { zcode: rest }
55550
+ },
55551
+ body: this.body,
55552
+ relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
55553
+ relativeFilePath: this.getRelativeFilePath(),
55554
+ validate: true
55555
+ });
55556
+ }
55557
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
55558
+ const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
55559
+ const zcodeSection = rulesyncFrontmatter.zcode ?? {};
55560
+ const zcodeFrontmatter = {
55561
+ name: rulesyncFrontmatter.name,
55562
+ description: rulesyncFrontmatter.description,
55563
+ ...zcodeSection
55564
+ };
55565
+ const body = rulesyncSubagent.getBody();
55566
+ const fileContent = stringifyFrontmatter(body, zcodeFrontmatter, { avoidBlockScalars: true });
55567
+ const paths = this.getSettablePaths({ global });
55568
+ return new ZcodeSubagent({
55569
+ outputRoot,
55570
+ frontmatter: zcodeFrontmatter,
55571
+ body,
55572
+ relativeDirPath: paths.relativeDirPath,
55573
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
55574
+ fileContent,
55575
+ validate,
55576
+ global
55577
+ });
55578
+ }
55579
+ validate() {
55580
+ if (!this.frontmatter) return {
55581
+ success: true,
55582
+ error: null
55583
+ };
55584
+ const result = ZcodeSubagentFrontmatterSchema.safeParse(this.frontmatter);
55585
+ if (result.success) return {
55586
+ success: true,
55587
+ error: null
55588
+ };
55589
+ else return {
55590
+ success: false,
55591
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
55592
+ };
55593
+ }
55594
+ static isTargetedByRulesyncSubagent(rulesyncSubagent) {
55595
+ return this.isTargetedByRulesyncSubagentDefault({
55596
+ rulesyncSubagent,
55597
+ toolTarget: "zcode"
55598
+ });
55599
+ }
55600
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true, global = false }) {
55601
+ const dirPath = relativeDirPath ?? this.getSettablePaths({ global }).relativeDirPath;
55602
+ const filePath = (0, node_path.join)(outputRoot, dirPath, relativeFilePath);
55603
+ const fileContent = await readFileContent(filePath);
55604
+ const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
55605
+ const result = ZcodeSubagentFrontmatterSchema.safeParse(frontmatter);
55606
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
55607
+ return new ZcodeSubagent({
55608
+ outputRoot,
55609
+ relativeDirPath: dirPath,
55610
+ relativeFilePath,
55611
+ frontmatter: result.data,
55612
+ body: content.trim(),
55613
+ fileContent,
55614
+ validate,
55615
+ global
55616
+ });
55617
+ }
55618
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
55619
+ return new ZcodeSubagent({
55620
+ outputRoot,
55621
+ relativeDirPath,
55622
+ relativeFilePath,
55623
+ frontmatter: {
55624
+ name: "",
55625
+ description: ""
55626
+ },
55627
+ body: "",
55628
+ fileContent: "",
55629
+ validate: false
55630
+ });
55631
+ }
55632
+ };
55633
+ //#endregion
54660
55634
  //#region src/features/subagents/zoocode-subagent.ts
54661
55635
  /**
54662
55636
  * Subagent (custom-mode) generator for **Zoo Code**, the community
@@ -54721,6 +55695,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54721
55695
  ["agentsmd", {
54722
55696
  class: AgentsmdSubagent,
54723
55697
  meta: {
55698
+ supportsProject: true,
54724
55699
  supportsSimulated: true,
54725
55700
  supportsGlobal: false,
54726
55701
  filePattern: "*.md"
@@ -54729,6 +55704,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54729
55704
  ["antigravity-cli", {
54730
55705
  class: AntigravityCliSubagent,
54731
55706
  meta: {
55707
+ supportsProject: true,
54732
55708
  supportsSimulated: false,
54733
55709
  supportsGlobal: true,
54734
55710
  filePattern: "*.md"
@@ -54737,6 +55713,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54737
55713
  ["antigravity-ide", {
54738
55714
  class: AntigravityIdeSubagent,
54739
55715
  meta: {
55716
+ supportsProject: true,
54740
55717
  supportsSimulated: false,
54741
55718
  supportsGlobal: true,
54742
55719
  filePattern: "*.md"
@@ -54745,6 +55722,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54745
55722
  ["antigravity-plugin", {
54746
55723
  class: AntigravityPluginSubagent,
54747
55724
  meta: {
55725
+ supportsProject: true,
54748
55726
  supportsSimulated: false,
54749
55727
  supportsGlobal: false,
54750
55728
  filePattern: "*.md"
@@ -54753,6 +55731,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54753
55731
  ["augmentcode", {
54754
55732
  class: AugmentcodeSubagent,
54755
55733
  meta: {
55734
+ supportsProject: true,
54756
55735
  supportsSimulated: false,
54757
55736
  supportsGlobal: true,
54758
55737
  filePattern: "*.md"
@@ -54761,6 +55740,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54761
55740
  ["claudecode", {
54762
55741
  class: ClaudecodeSubagent,
54763
55742
  meta: {
55743
+ supportsProject: true,
54764
55744
  supportsSimulated: false,
54765
55745
  supportsGlobal: true,
54766
55746
  filePattern: "*.md"
@@ -54769,6 +55749,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54769
55749
  ["claudecode-plugin", {
54770
55750
  class: ClaudecodePluginSubagent,
54771
55751
  meta: {
55752
+ supportsProject: true,
54772
55753
  supportsSimulated: false,
54773
55754
  supportsGlobal: false,
54774
55755
  filePattern: "*.md"
@@ -54777,6 +55758,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54777
55758
  ["claudecode-legacy", {
54778
55759
  class: ClaudecodeSubagent,
54779
55760
  meta: {
55761
+ supportsProject: true,
54780
55762
  supportsSimulated: false,
54781
55763
  supportsGlobal: true,
54782
55764
  filePattern: "*.md"
@@ -54785,6 +55767,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54785
55767
  ["cline", {
54786
55768
  class: ClineSubagent,
54787
55769
  meta: {
55770
+ supportsProject: true,
54788
55771
  supportsSimulated: false,
54789
55772
  supportsGlobal: true,
54790
55773
  filePattern: "*.{yaml,yml}"
@@ -54793,6 +55776,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54793
55776
  ["codexcli", {
54794
55777
  class: CodexCliSubagent,
54795
55778
  meta: {
55779
+ supportsProject: true,
54796
55780
  supportsSimulated: false,
54797
55781
  supportsGlobal: true,
54798
55782
  filePattern: "*.toml"
@@ -54801,6 +55785,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54801
55785
  ["copilot", {
54802
55786
  class: CopilotSubagent,
54803
55787
  meta: {
55788
+ supportsProject: true,
54804
55789
  supportsSimulated: false,
54805
55790
  supportsGlobal: true,
54806
55791
  filePattern: "*.md"
@@ -54809,6 +55794,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54809
55794
  ["copilotcli", {
54810
55795
  class: CopilotcliSubagent,
54811
55796
  meta: {
55797
+ supportsProject: true,
54812
55798
  supportsSimulated: false,
54813
55799
  supportsGlobal: true,
54814
55800
  filePattern: "*.agent.md"
@@ -54817,6 +55803,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54817
55803
  ["cursor", {
54818
55804
  class: CursorSubagent,
54819
55805
  meta: {
55806
+ supportsProject: true,
54820
55807
  supportsSimulated: false,
54821
55808
  supportsGlobal: true,
54822
55809
  filePattern: "*.md"
@@ -54825,6 +55812,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54825
55812
  ["deepagents", {
54826
55813
  class: DeepagentsSubagent,
54827
55814
  meta: {
55815
+ supportsProject: true,
54828
55816
  supportsSimulated: false,
54829
55817
  supportsGlobal: true,
54830
55818
  filePattern: (0, node_path.join)("*", "AGENTS.md")
@@ -54833,6 +55821,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54833
55821
  ["devin", {
54834
55822
  class: DevinSubagent,
54835
55823
  meta: {
55824
+ supportsProject: true,
54836
55825
  supportsSimulated: false,
54837
55826
  supportsGlobal: true,
54838
55827
  filePattern: (0, node_path.join)("*", "AGENT.md")
@@ -54841,6 +55830,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54841
55830
  ["factorydroid", {
54842
55831
  class: FactorydroidSubagent,
54843
55832
  meta: {
55833
+ supportsProject: true,
54844
55834
  supportsSimulated: false,
54845
55835
  supportsGlobal: true,
54846
55836
  filePattern: "*.md"
@@ -54849,6 +55839,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54849
55839
  ["goose", {
54850
55840
  class: GooseSubagent,
54851
55841
  meta: {
55842
+ supportsProject: true,
54852
55843
  supportsSimulated: false,
54853
55844
  supportsGlobal: true,
54854
55845
  filePattern: "*.md"
@@ -54857,6 +55848,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54857
55848
  ["hermesagent", {
54858
55849
  class: HermesagentSubagent,
54859
55850
  meta: {
55851
+ supportsProject: true,
54860
55852
  supportsGlobal: true,
54861
55853
  supportsSimulated: false,
54862
55854
  filePattern: "*.json"
@@ -54865,6 +55857,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54865
55857
  ["grokcli", {
54866
55858
  class: GrokcliSubagent,
54867
55859
  meta: {
55860
+ supportsProject: true,
54868
55861
  supportsSimulated: false,
54869
55862
  supportsGlobal: true,
54870
55863
  filePattern: "*.md"
@@ -54873,6 +55866,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54873
55866
  ["junie", {
54874
55867
  class: JunieSubagent,
54875
55868
  meta: {
55869
+ supportsProject: true,
54876
55870
  supportsSimulated: false,
54877
55871
  supportsGlobal: true,
54878
55872
  filePattern: "*.md"
@@ -54881,6 +55875,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54881
55875
  ["kiro", {
54882
55876
  class: KiroSubagent,
54883
55877
  meta: {
55878
+ supportsProject: true,
54884
55879
  supportsSimulated: false,
54885
55880
  supportsGlobal: false,
54886
55881
  filePattern: "*.json"
@@ -54889,6 +55884,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54889
55884
  ["kiro-cli", {
54890
55885
  class: KiroCliSubagent,
54891
55886
  meta: {
55887
+ supportsProject: true,
54892
55888
  supportsSimulated: false,
54893
55889
  supportsGlobal: true,
54894
55890
  filePattern: "*.json"
@@ -54897,6 +55893,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54897
55893
  ["kiro-ide", {
54898
55894
  class: KiroIdeSubagent,
54899
55895
  meta: {
55896
+ supportsProject: true,
54900
55897
  supportsSimulated: false,
54901
55898
  supportsGlobal: true,
54902
55899
  filePattern: "*.md"
@@ -54905,6 +55902,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54905
55902
  ["kilo", {
54906
55903
  class: KiloSubagent,
54907
55904
  meta: {
55905
+ supportsProject: true,
54908
55906
  supportsSimulated: false,
54909
55907
  supportsGlobal: true,
54910
55908
  filePattern: "*.md"
@@ -54913,6 +55911,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54913
55911
  ["kimi-code", {
54914
55912
  class: KimiCodeSubagent,
54915
55913
  meta: {
55914
+ supportsProject: true,
54916
55915
  supportsSimulated: false,
54917
55916
  supportsGlobal: true,
54918
55917
  filePattern: (0, node_path.join)("**", "*.md")
@@ -54921,6 +55920,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54921
55920
  ["opencode", {
54922
55921
  class: OpenCodeSubagent,
54923
55922
  meta: {
55923
+ supportsProject: true,
54924
55924
  supportsSimulated: false,
54925
55925
  supportsGlobal: true,
54926
55926
  filePattern: "*.md"
@@ -54929,6 +55929,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54929
55929
  ["qwencode", {
54930
55930
  class: QwencodeSubagent,
54931
55931
  meta: {
55932
+ supportsProject: true,
54932
55933
  supportsSimulated: false,
54933
55934
  supportsGlobal: true,
54934
55935
  filePattern: "*.md"
@@ -54937,6 +55938,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54937
55938
  ["reasonix", {
54938
55939
  class: ReasonixSubagent,
54939
55940
  meta: {
55941
+ supportsProject: true,
54940
55942
  supportsSimulated: false,
54941
55943
  supportsGlobal: true,
54942
55944
  filePattern: (0, node_path.join)("*", "SKILL.md")
@@ -54945,6 +55947,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54945
55947
  ["roo", {
54946
55948
  class: RooSubagent,
54947
55949
  meta: {
55950
+ supportsProject: true,
54948
55951
  supportsSimulated: false,
54949
55952
  supportsGlobal: false,
54950
55953
  filePattern: ".roomodes"
@@ -54953,6 +55956,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54953
55956
  ["zoocode", {
54954
55957
  class: ZoocodeSubagent,
54955
55958
  meta: {
55959
+ supportsProject: true,
54956
55960
  supportsSimulated: false,
54957
55961
  supportsGlobal: false,
54958
55962
  filePattern: ".roomodes"
@@ -54961,6 +55965,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54961
55965
  ["rovodev", {
54962
55966
  class: RovodevSubagent,
54963
55967
  meta: {
55968
+ supportsProject: true,
54964
55969
  supportsSimulated: false,
54965
55970
  supportsGlobal: true,
54966
55971
  filePattern: "*.md"
@@ -54969,6 +55974,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54969
55974
  ["takt", {
54970
55975
  class: TaktSubagent,
54971
55976
  meta: {
55977
+ supportsProject: true,
54972
55978
  supportsSimulated: false,
54973
55979
  supportsGlobal: true,
54974
55980
  filePattern: "*.md"
@@ -54977,10 +55983,20 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54977
55983
  ["vibe", {
54978
55984
  class: VibeSubagent,
54979
55985
  meta: {
55986
+ supportsProject: true,
54980
55987
  supportsSimulated: false,
54981
55988
  supportsGlobal: true,
54982
55989
  filePattern: "*.toml"
54983
55990
  }
55991
+ }],
55992
+ ["zcode", {
55993
+ class: ZcodeSubagent,
55994
+ meta: {
55995
+ supportsProject: false,
55996
+ supportsSimulated: false,
55997
+ supportsGlobal: true,
55998
+ filePattern: "*.md"
55999
+ }
54984
56000
  }]
54985
56001
  ]);
54986
56002
  const defaultGetFactory$1 = (target) => {
@@ -54989,7 +56005,9 @@ const defaultGetFactory$1 = (target) => {
54989
56005
  return factory;
54990
56006
  };
54991
56007
  const allToolTargetKeys$1 = [...toolSubagentFactories.keys()];
54992
- const subagentsProcessorToolTargets = allToolTargetKeys$1;
56008
+ const subagentsProcessorToolTargets = allToolTargetKeys$1.filter((target) => {
56009
+ return toolSubagentFactories.get(target)?.meta.supportsProject ?? false;
56010
+ });
54993
56011
  const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((target) => {
54994
56012
  return toolSubagentFactories.get(target)?.meta.supportsSimulated ?? false;
54995
56013
  });
@@ -55089,7 +56107,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
55089
56107
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
55090
56108
  return [];
55091
56109
  }
55092
- const mdFiles = (await listDirectoryFiles(subagentsDir)).filter((file) => file.endsWith(".md"));
56110
+ const mdFiles = (await listDirectoryEntryNames(subagentsDir)).filter((file) => file.endsWith(".md"));
55093
56111
  if (mdFiles.length === 0) {
55094
56112
  this.logger.debug(`No markdown files found in rulesync subagents directory: ${subagentsDir}`);
55095
56113
  return [];
@@ -63725,6 +64743,12 @@ Object.defineProperty(exports, "CONFLICTING_TARGET_PAIRS", {
63725
64743
  return CONFLICTING_TARGET_PAIRS;
63726
64744
  }
63727
64745
  });
64746
+ Object.defineProperty(exports, "CURATED_RULES_FEATURE_SUBDIR", {
64747
+ enumerable: true,
64748
+ get: function() {
64749
+ return CURATED_RULES_FEATURE_SUBDIR;
64750
+ }
64751
+ });
63728
64752
  Object.defineProperty(exports, "ChecksProcessor", {
63729
64753
  enumerable: true,
63730
64754
  get: function() {
@@ -64121,6 +65145,12 @@ Object.defineProperty(exports, "ToolTargetSchema", {
64121
65145
  return ToolTargetSchema;
64122
65146
  }
64123
65147
  });
65148
+ Object.defineProperty(exports, "WarningCollectingLogger", {
65149
+ enumerable: true,
65150
+ get: function() {
65151
+ return WarningCollectingLogger;
65152
+ }
65153
+ });
64124
65154
  Object.defineProperty(exports, "__toESM", {
64125
65155
  enumerable: true,
64126
65156
  get: function() {
@@ -64205,12 +65235,6 @@ Object.defineProperty(exports, "findControlCharacter", {
64205
65235
  return findControlCharacter;
64206
65236
  }
64207
65237
  });
64208
- Object.defineProperty(exports, "findFilesByGlobs", {
64209
- enumerable: true,
64210
- get: function() {
64211
- return findFilesByGlobs;
64212
- }
64213
- });
64214
65238
  Object.defineProperty(exports, "formatError", {
64215
65239
  enumerable: true,
64216
65240
  get: function() {
@@ -64259,6 +65283,12 @@ Object.defineProperty(exports, "getRulesyncSourceCandidates", {
64259
65283
  return getRulesyncSourceCandidates;
64260
65284
  }
64261
65285
  });
65286
+ Object.defineProperty(exports, "groupSpellingsByCaseFoldedIdentity", {
65287
+ enumerable: true,
65288
+ get: function() {
65289
+ return groupSpellingsByCaseFoldedIdentity;
65290
+ }
65291
+ });
64262
65292
  Object.defineProperty(exports, "hasDeceptiveHiddenCharacters", {
64263
65293
  enumerable: true,
64264
65294
  get: function() {
@@ -64283,6 +65313,12 @@ Object.defineProperty(exports, "isFileNotFoundError", {
64283
65313
  return isFileNotFoundError;
64284
65314
  }
64285
65315
  });
65316
+ Object.defineProperty(exports, "isFileSystemError", {
65317
+ enumerable: true,
65318
+ get: function() {
65319
+ return isFileSystemError;
65320
+ }
65321
+ });
64286
65322
  Object.defineProperty(exports, "isPackagingToolTarget", {
64287
65323
  enumerable: true,
64288
65324
  get: function() {
@@ -64295,10 +65331,16 @@ Object.defineProperty(exports, "isSymlink", {
64295
65331
  return isSymlink;
64296
65332
  }
64297
65333
  });
64298
- Object.defineProperty(exports, "listDirectoryFiles", {
65334
+ Object.defineProperty(exports, "listDirectoryEntryNames", {
64299
65335
  enumerable: true,
64300
65336
  get: function() {
64301
- return listDirectoryFiles;
65337
+ return listDirectoryEntryNames;
65338
+ }
65339
+ });
65340
+ Object.defineProperty(exports, "listFilePathsRecursively", {
65341
+ enumerable: true,
65342
+ get: function() {
65343
+ return listFilePathsRecursively;
64302
65344
  }
64303
65345
  });
64304
65346
  Object.defineProperty(exports, "listSubdirectoryNames", {
@@ -64427,6 +65469,12 @@ Object.defineProperty(exports, "stripControlCharacters", {
64427
65469
  return stripControlCharacters;
64428
65470
  }
64429
65471
  });
65472
+ Object.defineProperty(exports, "stripControlCharactersKeepingLineFeeds", {
65473
+ enumerable: true,
65474
+ get: function() {
65475
+ return stripControlCharactersKeepingLineFeeds;
65476
+ }
65477
+ });
64430
65478
  Object.defineProperty(exports, "stripHiddenCharacters", {
64431
65479
  enumerable: true,
64432
65480
  get: function() {
@@ -64439,12 +65487,30 @@ Object.defineProperty(exports, "toPosixPath", {
64439
65487
  return toPosixPath;
64440
65488
  }
64441
65489
  });
65490
+ Object.defineProperty(exports, "truncateText", {
65491
+ enumerable: true,
65492
+ get: function() {
65493
+ return truncateText;
65494
+ }
65495
+ });
64442
65496
  Object.defineProperty(exports, "warnOnConflictingFlags", {
64443
65497
  enumerable: true,
64444
65498
  get: function() {
64445
65499
  return warnOnConflictingFlags;
64446
65500
  }
64447
65501
  });
65502
+ Object.defineProperty(exports, "withFallbackLoggerTarget", {
65503
+ enumerable: true,
65504
+ get: function() {
65505
+ return withFallbackLoggerTarget;
65506
+ }
65507
+ });
65508
+ Object.defineProperty(exports, "withWarnOnceScope", {
65509
+ enumerable: true,
65510
+ get: function() {
65511
+ return withWarnOnceScope;
65512
+ }
65513
+ });
64448
65514
  Object.defineProperty(exports, "writeFileBuffer", {
64449
65515
  enumerable: true,
64450
65516
  get: function() {