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.
@@ -6,15 +6,221 @@ import { parse, parseTree, printParseErrorCode } from "jsonc-parser";
6
6
  import os from "node:os";
7
7
  import { intersection, kebabCase, uniq } from "es-toolkit";
8
8
  import { globbySync, isGitIgnoredSync } from "globby";
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ import { format, isDeepStrictEqual } from "node:util";
9
11
  import matter from "gray-matter";
10
12
  import { YAMLException, dump, load } from "js-yaml";
11
13
  import { omit } from "es-toolkit/object";
12
14
  import { constants } from "node:fs";
13
15
  import { createHash } from "node:crypto";
14
- import { isDeepStrictEqual } from "node:util";
15
16
  import * as smolToml from "smol-toml";
16
17
  import { parse as parse$1, stringify } from "smol-toml";
17
18
  import { encode } from "@toon-format/toon";
19
+ //#region src/utils/control-characters.ts
20
+ /**
21
+ * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
22
+ * introducer U+009B), the bidirectional overrides and isolates, the Unicode
23
+ * line and paragraph separators, and the plain LRM/RLM/ALM marks. A name or
24
+ * value copied out of an untrusted config file, a fetched repository, or a
25
+ * tool's own settings file must never reach the terminal with these intact:
26
+ * they let the text forge log lines, reorder what is printed around them, or
27
+ * inject escape sequences. LRM, RLM and the Arabic letter mark open no bidi
28
+ * scope of their own, but they still reorder the neutral characters beside
29
+ * them, so they go too — a diagnostic line is not the place to preserve the
30
+ * typography of a right-to-left name.
31
+ */
32
+ const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
33
+ /**
34
+ * Removes every control character from `text` so it is safe to splice into a
35
+ * log line or other terminal output.
36
+ */
37
+ function stripControlCharacters(text) {
38
+ return text.replace(CONTROL_CHARACTERS_PATTERN, "");
39
+ }
40
+ /**
41
+ * Removes every control character from `text` except the line feed, so a
42
+ * message written to be read over several lines still is.
43
+ *
44
+ * `stripControlCharacters` takes newlines out because a diagnostic is one line
45
+ * and a name that carries one can forge a second. An error message is not: a
46
+ * lock file names the process holding it over several lines, and the MCP
47
+ * `generate` failure lists one unreadable source per line. The carriage return
48
+ * still goes, since on its own it paints over the line already written — which
49
+ * is why this splits on the line feed and strips each line rather than carrying
50
+ * a second character class that has to be kept in step with the first.
51
+ */
52
+ function stripControlCharactersKeepingLineFeeds(text) {
53
+ return text.split("\n").map(stripControlCharacters).join("\n");
54
+ }
55
+ /**
56
+ * Matches the characters that take no width of their own.
57
+ *
58
+ * `Default_Ignorable_Code_Point` is the Unicode property for exactly this — the
59
+ * zero-width joiners, the soft hyphen, the variation selectors, the Hangul
60
+ * fillers, the tag characters — and the format category `Cf` covers the few
61
+ * that sit outside it, such as the interlinear annotation marks. Both are used
62
+ * rather than a list of ranges because a list has to be revisited every time
63
+ * Unicode adds one, and the one that is missed is the one an attacker reaches
64
+ * for: U+3164 HANGUL FILLER, the classic of the homograph domain names, is a
65
+ * letter of the Hangul script and would pass every check aimed at Latin.
66
+ *
67
+ * The braille blank is named on its own. It carries no dots, so it draws as
68
+ * nothing while belonging to neither set.
69
+ *
70
+ * None of these is a control character, so none is caught by
71
+ * `stripControlCharacters` — and none of them shows. A name that differs from
72
+ * another only by one of these is drawn exactly like it, which is why a name
73
+ * that carries one is not a name a user can be asked to judge.
74
+ */
75
+ const INVISIBLE_CHARACTERS_PATTERN = /[\p{Default_Ignorable_Code_Point}\p{Cf}\u2800]/gu;
76
+ /**
77
+ * Removes every zero-width and otherwise invisible character from `text`.
78
+ *
79
+ * Kept apart from `stripControlCharacters` because the two answer different
80
+ * questions. That one asks what is safe to print; this one asks whether a name
81
+ * shows everything it contains, which is what a prompt needs before it offers
82
+ * the name as something to pick.
83
+ */
84
+ function stripInvisibleCharacters(text) {
85
+ return text.replace(INVISIBLE_CHARACTERS_PATTERN, "");
86
+ }
87
+ /**
88
+ * Removes every character that does not show: the control characters and the
89
+ * invisible ones alike.
90
+ *
91
+ * This is the form a name has to survive unchanged before it can be offered as
92
+ * something to choose. Callers that need the whole answer should reach for this
93
+ * rather than composing the two strippers themselves, so that the order — and
94
+ * the definition of "hidden" — lives in one place.
95
+ */
96
+ function stripHiddenCharacters(text) {
97
+ return stripInvisibleCharacters(stripControlCharacters(text));
98
+ }
99
+ /**
100
+ * The invisible characters that do a job in some scripts rather than only
101
+ * hiding: the two zero-width joiners and the variation selectors.
102
+ *
103
+ * A Persian or Indic name spells a word with ZWNJ (U+200C) in it, and an emoji
104
+ * name is a chain of ZWJ (U+200D) and variation selectors. Refusing those
105
+ * outright would refuse names that are written the only way their script writes
106
+ * them, so they are judged by where they sit rather than by what they are.
107
+ */
108
+ const ZERO_WIDTH_JOINER_PATTERN = /\u200c|\u200d/u;
109
+ const VARIATION_SELECTOR_PATTERN = /[\u{fe00}-\u{fe0f}]|[\u{e0100}-\u{e01ef}]/u;
110
+ /** The twelve characters a keycap can be built on, per ED-14 of UTS #51. */
111
+ const KEYCAP_BASE_PATTERN = /[0-9#*]/u;
112
+ /** The selector that asks for the emoji form of the character before it. */
113
+ const EMOJI_PRESENTATION_SELECTOR$1 = "️";
114
+ /** U+20E3, which draws the box the base and the selector go inside. */
115
+ const COMBINING_ENCLOSING_KEYCAP = "⃣";
116
+ /**
117
+ * Whether the three characters are an emoji keycap, spelled as UTS #51 spells
118
+ * it: `Emoji_Keycap_Sequence := [0-9#*] FE0F 20E3`.
119
+ *
120
+ * The twelve bases are the only ASCII characters Unicode gives the `Emoji`
121
+ * property to, and not one of them is a pictograph — `1` is a digit and `#` is
122
+ * punctuation — so the joining list below cannot see the sequence, and a
123
+ * directory named `1\u{fe0f}\u{20e3}` would be turned away as a digit padded
124
+ * with a variation selector. It is not padding: the selector is what asks for
125
+ * the emoji form of the digit, and the enclosing keycap behind it is what draws
126
+ * the box around it. Both neighbors are required, which is what keeps the
127
+ * exception to the sequence rather than handing it to every digit in every
128
+ * name: `pdf1` with a variation selector and no keycap after it is padding
129
+ * still, and is refused still.
130
+ *
131
+ * @see https://www.unicode.org/reports/tr51/#def_emoji_keycap_sequence
132
+ */
133
+ function isKeycapSequence(params) {
134
+ const { base, selector, following } = params;
135
+ return base !== void 0 && KEYCAP_BASE_PATTERN.test(base) && selector === EMOJI_PRESENTATION_SELECTOR$1 && following === COMBINING_ENCLOSING_KEYCAP;
136
+ }
137
+ /**
138
+ * The characters a joiner has work to do beside: the scripts whose words are
139
+ * written with one, and the pictographs an emoji sequence is built from.
140
+ *
141
+ * A list of what may join rather than of what may not, because the two are not
142
+ * the same size. `pdf` with a ZWNJ between the d and the f is `pdf` on screen
143
+ * and a different directory underneath, and the same is true of `設定` with a
144
+ * ZWJ after the first character: neither Latin nor Han joins anything that way,
145
+ * and nor does Cyrillic, Greek, Hangul or kana. Naming the scripts that do —
146
+ * the Arabic family, the Indic ones, Mongolian, and the pictographs — is what
147
+ * keeps the exception to the names that need it, instead of handing it to every
148
+ * writing system that is merely not Latin.
149
+ */
150
+ 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;
151
+ /** Non-global copies, because `test` on a global regex carries state between calls. */
152
+ const CONTROL_CHARACTER_PATTERN = new RegExp(CONTROL_CHARACTERS_PATTERN.source, "u");
153
+ const INVISIBLE_CHARACTER_PATTERN = new RegExp(INVISIBLE_CHARACTERS_PATTERN.source, "u");
154
+ /**
155
+ * Whether `text` carries a hidden character that is there to hide something.
156
+ *
157
+ * This is the question a name has to answer before it can be offered as
158
+ * something to pick, and it is a narrower one than `stripHiddenCharacters`
159
+ * answers. Every control character counts, and so does every invisible
160
+ * character — except a joiner or variation selector standing where its own
161
+ * script would put one, which is to say beside a character from a script that
162
+ * is written with joiners, or beside a pictograph.
163
+ *
164
+ * A joiner is held to both of its neighbors, since it exists to bind two
165
+ * characters and a name that ends in one is binding nothing: `設定` with a ZWJ
166
+ * after it is `設定` on screen and a second directory underneath. A variation
167
+ * selector is held only to the character before it, which is the one it selects
168
+ * a form for, and which is why an emoji name may end in one.
169
+ *
170
+ * The keycap sequence is the one emoji the joining list cannot recognize on its
171
+ * own, since what it is built on is a digit or an ASCII sign rather than a
172
+ * pictograph, so it is matched whole instead.
173
+ *
174
+ * Han is not on the joining list, so an ideographic variation sequence — a Han
175
+ * character followed by one of U+E0100 onward — is refused along with the rest.
176
+ * That is the intended trade: no skill directory here is named with one, and
177
+ * the pair is drawn as the bare character on every terminal that has no font
178
+ * for the variant, which is the shape the check exists to refuse.
179
+ *
180
+ * The test is a heuristic in place of the CONTEXTJ joining rules of IDNA,
181
+ * which decide the same question by the joining type of the characters around
182
+ * the joiner. It errs toward accepting a name written in a script that needs
183
+ * these characters, and toward rejecting one that mixes them into Latin, where
184
+ * they can only be padding.
185
+ */
186
+ function hasDeceptiveHiddenCharacters(text) {
187
+ const characters = [...text];
188
+ const joinsCharacter = (neighbor) => neighbor !== void 0 && JOINING_CONTEXT_PATTERN.test(neighbor);
189
+ return characters.some((character, index) => {
190
+ if (CONTROL_CHARACTER_PATTERN.test(character)) return true;
191
+ if (!INVISIBLE_CHARACTER_PATTERN.test(character)) return false;
192
+ if (VARIATION_SELECTOR_PATTERN.test(character)) {
193
+ if (isKeycapSequence({
194
+ base: characters[index - 1],
195
+ selector: character,
196
+ following: characters[index + 1]
197
+ })) return false;
198
+ return !joinsCharacter(characters[index - 1]);
199
+ }
200
+ if (!ZERO_WIDTH_JOINER_PATTERN.test(character)) return true;
201
+ return !joinsCharacter(characters[index - 1]) || !joinsCharacter(characters[index + 1]);
202
+ });
203
+ }
204
+ //#endregion
205
+ //#region src/utils/truncate.ts
206
+ /**
207
+ * Cut `text` to `maxLength` without splitting a character in half.
208
+ *
209
+ * `String.prototype.slice` counts UTF-16 units, so cutting inside a surrogate
210
+ * pair leaves a lone surrogate that the next encoder turns into a replacement
211
+ * character, and cutting inside a `\uXXXX` escape that `JSON.stringify` wrote
212
+ * leaves a dangling backslash. Diagnostics quote files rulesync did not write,
213
+ * so both are reachable from a repository's own content rather than only from
214
+ * a hand-crafted string.
215
+ */
216
+ function truncateText({ text, maxLength, suffix }) {
217
+ if (text.length <= maxLength) return text;
218
+ const characters = Array.from(text.slice(0, maxLength * 2 + 2));
219
+ if (characters.length <= maxLength) return text;
220
+ const cut = characters.slice(0, maxLength).join("");
221
+ return `${(/\\*$/.exec(cut)?.[0].length ?? 0) % 2 === 0 ? cut : cut.slice(0, -1)}${suffix}`;
222
+ }
223
+ //#endregion
18
224
  //#region src/utils/error.ts
19
225
  /**
20
226
  * Convert various error types to a readable error message
@@ -39,10 +245,52 @@ import { encode } from "@toon-format/toon";
39
245
  function isZodErrorLike(error) {
40
246
  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");
41
247
  }
248
+ /**
249
+ * How much of a Zod error the formatted message spells out.
250
+ *
251
+ * One `safeParse` of a large invalid document produces an issue per offending
252
+ * node, each carrying the path and message, so the raw expansion is bounded by
253
+ * the size of the input rather than by anything rulesync decides — and the
254
+ * formatted message no longer stops at a terminal: it becomes the `message` of
255
+ * a `--json` failure document and of an MCP result. The first few issues are
256
+ * what tells the reader which file to open; the rest is the same information
257
+ * again, at whatever length the input chose.
258
+ */
259
+ const MAX_ZOD_ISSUES_LENGTH = 2e3;
260
+ /**
261
+ * How much of any other error the formatted message spells out.
262
+ *
263
+ * Larger than the Zod bound because the text is the error's own sentence rather
264
+ * than a re-listing of one issue per offending node, and because the MCP
265
+ * `generate` failure hands this one a line per unreadable source. Bounded all
266
+ * the same: a parser quotes the offending line verbatim, and a minified file is
267
+ * one line the length of the file.
268
+ */
269
+ const MAX_ERROR_MESSAGE_LENGTH = 8e3;
270
+ /**
271
+ * Strip and bound an error message that is about to be read by something other
272
+ * than a terminal — a `--json` failure document, an MCP result.
273
+ *
274
+ * The line feed stays: several messages are deliberately written over more than
275
+ * one line, and running them together would cost more than the newline can do
276
+ * here. Everything that reorders the text around it, or that an escape sequence
277
+ * is written with, goes.
278
+ */
279
+ function boundErrorMessage(text) {
280
+ return truncateText({
281
+ text: stripControlCharactersKeepingLineFeeds(text),
282
+ maxLength: MAX_ERROR_MESSAGE_LENGTH,
283
+ suffix: "…(truncated)"
284
+ });
285
+ }
42
286
  function formatError(error) {
43
- if (error instanceof ZodError || isZodErrorLike(error)) return `Zod raw error: ${JSON.stringify(error.issues)}`;
44
- if (error instanceof Error) return `${error.name}: ${error.message}`;
45
- return String(error);
287
+ if (error instanceof ZodError || isZodErrorLike(error)) return `Zod raw error: ${truncateText({
288
+ text: stripControlCharacters(JSON.stringify(error.issues)),
289
+ maxLength: MAX_ZOD_ISSUES_LENGTH,
290
+ suffix: "…(truncated)"
291
+ })}`;
292
+ if (error instanceof Error) return boundErrorMessage(`${error.name}: ${error.message}`);
293
+ return boundErrorMessage(String(error));
46
294
  }
47
295
  //#endregion
48
296
  //#region src/types/features.ts
@@ -336,7 +584,8 @@ const subagentsProcessorToolTargetTuple = [
336
584
  "zoocode",
337
585
  "rovodev",
338
586
  "takt",
339
- "vibe"
587
+ "vibe",
588
+ "zcode"
340
589
  ];
341
590
  const skillsProcessorToolTargetTuple = [
342
591
  "agentsmd",
@@ -516,138 +765,6 @@ async function mapWithConcurrency({ items, limit, mapper }) {
516
765
  return results;
517
766
  }
518
767
  //#endregion
519
- //#region src/utils/control-characters.ts
520
- /**
521
- * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
522
- * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
523
- * line and paragraph separators, and the plain LRM/RLM/ALM marks. A name or value
524
- * copied out of an untrusted config file, a fetched repository, or a tool's own
525
- * settings file must never reach the terminal with these intact: they let the
526
- * text forge log lines, reorder what is printed around them, or inject escape
527
- * sequences. LRM, RLM and the Arabic letter mark open no bidi scope of their
528
- * own, but they still reorder the neutral characters beside them, so they go too — a diagnostic line is not the
529
- * place to preserve the typography of a right-to-left name.
530
- */
531
- const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
532
- /**
533
- * Removes every control character from `text` so it is safe to splice into a
534
- * log line or other terminal output.
535
- */
536
- function stripControlCharacters(text) {
537
- return text.replace(CONTROL_CHARACTERS_PATTERN, "");
538
- }
539
- /**
540
- * Matches the characters that take no width of their own.
541
- *
542
- * `Default_Ignorable_Code_Point` is the Unicode property for exactly this — the
543
- * zero-width joiners, the soft hyphen, the variation selectors, the Hangul
544
- * fillers, the tag characters — and the format category `Cf` covers the few
545
- * that sit outside it, such as the interlinear annotation marks. Both are used
546
- * rather than a list of ranges because a list has to be revisited every time
547
- * Unicode adds one, and the one that is missed is the one an attacker reaches
548
- * for: U+3164 HANGUL FILLER, the classic of the homograph domain names, is a
549
- * letter of the Hangul script and would pass every check aimed at Latin.
550
- *
551
- * The braille blank is named on its own. It carries no dots, so it draws as
552
- * nothing while belonging to neither set.
553
- *
554
- * None of these is a control character, so none is caught by
555
- * `stripControlCharacters` — and none of them shows. A name that differs from
556
- * another only by one of these is drawn exactly like it, which is why a name
557
- * that carries one is not a name a user can be asked to judge.
558
- */
559
- const INVISIBLE_CHARACTERS_PATTERN = /[\p{Default_Ignorable_Code_Point}\p{Cf}\u2800]/gu;
560
- /**
561
- * Removes every zero-width and otherwise invisible character from `text`.
562
- *
563
- * Kept apart from `stripControlCharacters` because the two answer different
564
- * questions. That one asks what is safe to print; this one asks whether a name
565
- * shows everything it contains, which is what a prompt needs before it offers
566
- * the name as something to pick.
567
- */
568
- function stripInvisibleCharacters(text) {
569
- return text.replace(INVISIBLE_CHARACTERS_PATTERN, "");
570
- }
571
- /**
572
- * Removes every character that does not show: the control characters and the
573
- * invisible ones alike.
574
- *
575
- * This is the form a name has to survive unchanged before it can be offered as
576
- * something to choose. Callers that need the whole answer should reach for this
577
- * rather than composing the two strippers themselves, so that the order — and
578
- * the definition of "hidden" — lives in one place.
579
- */
580
- function stripHiddenCharacters(text) {
581
- return stripInvisibleCharacters(stripControlCharacters(text));
582
- }
583
- /**
584
- * The invisible characters that do a job in some scripts rather than only
585
- * hiding: the two zero-width joiners and the variation selectors.
586
- *
587
- * A Persian or Indic name spells a word with ZWNJ (U+200C) in it, and an emoji
588
- * name is a chain of ZWJ (U+200D) and variation selectors. Refusing those
589
- * outright would refuse names that are written the only way their script writes
590
- * them, so they are judged by where they sit rather than by what they are.
591
- */
592
- const ZERO_WIDTH_JOINER_PATTERN = /\u200c|\u200d/u;
593
- const VARIATION_SELECTOR_PATTERN = /[\u{fe00}-\u{fe0f}]|[\u{e0100}-\u{e01ef}]/u;
594
- /**
595
- * The characters a joiner has work to do beside: the scripts whose words are
596
- * written with one, and the pictographs an emoji sequence is built from.
597
- *
598
- * A list of what may join rather than of what may not, because the two are not
599
- * the same size. `pdf` with a ZWNJ between the d and the f is `pdf` on screen
600
- * and a different directory underneath, and the same is true of `設定` with a
601
- * ZWJ after the first character: neither Latin nor Han joins anything that way,
602
- * and nor does Cyrillic, Greek, Hangul or kana. Naming the scripts that do —
603
- * the Arabic family, the Indic ones, Mongolian, and the pictographs — is what
604
- * keeps the exception to the names that need it, instead of handing it to every
605
- * writing system that is merely not Latin.
606
- */
607
- 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;
608
- /** Non-global copies, because `test` on a global regex carries state between calls. */
609
- const CONTROL_CHARACTER_PATTERN = new RegExp(CONTROL_CHARACTERS_PATTERN.source, "u");
610
- const INVISIBLE_CHARACTER_PATTERN = new RegExp(INVISIBLE_CHARACTERS_PATTERN.source, "u");
611
- /**
612
- * Whether `text` carries a hidden character that is there to hide something.
613
- *
614
- * This is the question a name has to answer before it can be offered as
615
- * something to pick, and it is a narrower one than `stripHiddenCharacters`
616
- * answers. Every control character counts, and so does every invisible
617
- * character — except a joiner or variation selector standing where its own
618
- * script would put one, which is to say beside a character from a script that
619
- * is written with joiners, or beside a pictograph.
620
- *
621
- * A joiner is held to both of its neighbors, since it exists to bind two
622
- * characters and a name that ends in one is binding nothing: `設定` with a ZWJ
623
- * after it is `設定` on screen and a second directory underneath. A variation
624
- * selector is held only to the character before it, which is the one it selects
625
- * a form for, and which is why an emoji name may end in one.
626
- *
627
- * Han is not on the joining list, so an ideographic variation sequence — a Han
628
- * character followed by one of U+E0100 onward — is refused along with the rest.
629
- * That is the intended trade: no skill directory here is named with one, and
630
- * the pair is drawn as the bare character on every terminal that has no font
631
- * for the variant, which is the shape the check exists to refuse.
632
- *
633
- * The test is a heuristic in place of the CONTEXTJ joining rules of IDNA,
634
- * which decide the same question by the joining type of the characters around
635
- * the joiner. It errs toward accepting a name written in a script that needs
636
- * these characters, and toward rejecting one that mixes them into Latin, where
637
- * they can only be padding.
638
- */
639
- function hasDeceptiveHiddenCharacters(text) {
640
- const characters = [...text];
641
- const joinsCharacter = (neighbor) => neighbor !== void 0 && JOINING_CONTEXT_PATTERN.test(neighbor);
642
- return characters.some((character, index) => {
643
- if (CONTROL_CHARACTER_PATTERN.test(character)) return true;
644
- if (!INVISIBLE_CHARACTER_PATTERN.test(character)) return false;
645
- if (VARIATION_SELECTOR_PATTERN.test(character)) return !joinsCharacter(characters[index - 1]);
646
- if (!ZERO_WIDTH_JOINER_PATTERN.test(character)) return true;
647
- return !joinsCharacter(characters[index - 1]) || !joinsCharacter(characters[index + 1]);
648
- });
649
- }
650
- //#endregion
651
768
  //#region src/utils/vitest.ts
652
769
  function isEnvTest() {
653
770
  return process.env.NODE_ENV === "test";
@@ -1004,7 +1121,16 @@ async function isSymlink(filepath) {
1004
1121
  return false;
1005
1122
  }
1006
1123
  }
1007
- async function listDirectoryFiles(dir) {
1124
+ /**
1125
+ * Every entry name directly under `dir`, of whatever kind, in the order the
1126
+ * filesystem reports them.
1127
+ *
1128
+ * Named for what it returns, because {@link listFileNames} sits beside it and
1129
+ * answers a narrower question: this one reports directories and links as well
1130
+ * as files, and reads an unreadable directory as an empty one instead of
1131
+ * failing. Picking this where the other was meant brings both of those back.
1132
+ */
1133
+ async function listDirectoryEntryNames(dir) {
1008
1134
  try {
1009
1135
  return await readdir(dir);
1010
1136
  } catch {
@@ -1016,6 +1142,20 @@ function countHiddenSegments(filePath) {
1016
1142
  return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
1017
1143
  }
1018
1144
  /**
1145
+ * A path the running platform produced, written posix-separated.
1146
+ *
1147
+ * Not {@link toPosixPath}, which rewrites every backslash whatever it means. That
1148
+ * is right for a path a caller spelled, which may be spelled either way; it is
1149
+ * wrong for one the platform handed back. On a posix platform a backslash is an
1150
+ * ordinary character in a name, so rewriting it folds `a\b` onto `a/b` -- two
1151
+ * different directories, and wherever the result is used as a file's identity the
1152
+ * second one to be seen is taken for a repeat of the first and dropped. On
1153
+ * Windows no name can hold a backslash, so the rewrite there is lossless.
1154
+ */
1155
+ function nativePathToPosix(filePath) {
1156
+ return sep === "\\" ? filePath.replaceAll("\\", "/") : filePath;
1157
+ }
1158
+ /**
1019
1159
  * The real file a path denotes, posix-separated so it compares against the globby results
1020
1160
  * that produce it. Two paths share an identity when they resolve to the very same file --
1021
1161
  * a link beside its target, a link into a shared tree, or a cycle that walks back into an
@@ -1023,19 +1163,61 @@ function countHiddenSegments(filePath) {
1023
1163
  */
1024
1164
  async function realFileIdentity(filePath) {
1025
1165
  try {
1026
- return toPosixPath(await realpath(filePath));
1166
+ return nativePathToPosix(await realpath(filePath));
1027
1167
  } catch {
1028
- return toPosixPath(filePath);
1168
+ return nativePathToPosix(filePath);
1029
1169
  }
1030
1170
  }
1031
1171
  /**
1172
+ * Where the file `targetPath` really denotes sits relative to the one `rootPath`
1173
+ * does, with every link on both sides resolved. Posix-separated, because the
1174
+ * resolved paths it is built from are; a caller that splits it must split on `/`
1175
+ * alone, which is also the only separator a real name can never contain.
1176
+ *
1177
+ * Both sides fall back to their literal path when they cannot be resolved, so an
1178
+ * unresolvable path reads as an escape rather than as a contained one.
1179
+ */
1180
+ async function resolvedRelativePath({ rootPath, targetPath }) {
1181
+ const [realRootPath, realTargetPath] = await Promise.all([realFileIdentity(rootPath), realFileIdentity(targetPath)]);
1182
+ return posix.relative(realRootPath, realTargetPath);
1183
+ }
1184
+ /**
1185
+ * Whether a posix relative path -- one {@link resolvedRelativePath} returned, or any
1186
+ * built the same way -- leads out of the root it was taken against.
1187
+ *
1188
+ * Exported so a caller that already holds the relative path can ask this of the very
1189
+ * path it goes on to read, instead of resolving both sides a second time and judging
1190
+ * a result it then has to trust is the same one.
1191
+ */
1192
+ function posixRelativePathEscapesRoot(relativePath) {
1193
+ return relativePath === ".." || relativePath.startsWith("../") || posix.isAbsolute(relativePath);
1194
+ }
1195
+ /**
1196
+ * How many trailing segments `filePath` and `identity` have in common.
1197
+ *
1198
+ * A path that walked through no link at all shares all of its own segments with
1199
+ * the file's identity; one that walked through a link named differently from
1200
+ * its target parts from the identity at that segment and shares only what
1201
+ * follows it. Counting from the end rather than testing the two for equality is
1202
+ * what lets the comparison hold for a path that is not itself resolved: a glob
1203
+ * rooted at a directory that is a link of its own gives every candidate the
1204
+ * same unresolved prefix, and only the segments below it decide.
1205
+ */
1206
+ function sharedTrailingSegments(filePath, identity) {
1207
+ const left = splitPathSegments(nativePathToPosix(filePath));
1208
+ const right = splitPathSegments(identity);
1209
+ let shared = 0;
1210
+ while (shared < left.length && shared < right.length && left[left.length - 1 - shared] === right[right.length - 1 - shared]) shared++;
1211
+ return shared;
1212
+ }
1213
+ /**
1032
1214
  * Pick the one path that represents a file among the paths that resolve to it.
1033
1215
  *
1034
- * The path that walked through no link at all wins outright: it is already the real one,
1035
- * so it equals the file's identity. That keeps the real location of a file as the path
1036
- * callers see, rather than an alias that happens to sort first -- a directory link named
1037
- * `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a cycle must not replace
1038
- * `sub/note.md` with the same file reached back through the cycle.
1216
+ * The path that walked through no link at all wins outright: it shares every one of its
1217
+ * segments with the file's identity, which no alias does. That keeps the real location of
1218
+ * a file as the path callers see, rather than an alias that happens to sort first -- a
1219
+ * directory link named `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a
1220
+ * cycle must not replace `sub/note.md` with the same file reached back through the cycle.
1039
1221
  * Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
1040
1222
  * named one represents the entry rather than a hidden alias that a hidden-entry rule may
1041
1223
  * then drop, taking the named path's content with it. `candidates` arrives in sorted
@@ -1043,8 +1225,9 @@ async function realFileIdentity(filePath) {
1043
1225
  */
1044
1226
  function chooseRepresentative(candidates, identity) {
1045
1227
  return candidates.reduce((best, candidate) => {
1046
- if (toPosixPath(best) === identity) return best;
1047
- if (toPosixPath(candidate) === identity) return candidate;
1228
+ const bestShared = sharedTrailingSegments(best, identity);
1229
+ const candidateShared = sharedTrailingSegments(candidate, identity);
1230
+ if (candidateShared !== bestShared) return candidateShared > bestShared ? candidate : best;
1048
1231
  return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
1049
1232
  });
1050
1233
  }
@@ -1142,7 +1325,7 @@ async function dedupeNamesByFileIdentity(params) {
1142
1325
  });
1143
1326
  const entriesByIdentity = /* @__PURE__ */ new Map();
1144
1327
  for (const [index, entry] of entries.entries()) {
1145
- const identity = identities[index] ?? toPosixPath(join(dirPath, entry.name));
1328
+ const identity = identities[index] ?? nativePathToPosix(join(dirPath, entry.name));
1146
1329
  const group = entriesByIdentity.get(identity);
1147
1330
  if (group === void 0) entriesByIdentity.set(identity, [entry]);
1148
1331
  else group.push(entry);
@@ -1194,6 +1377,94 @@ async function listFileNames(dirPath, options = {}) {
1194
1377
  nameFilter: options.nameFilter
1195
1378
  });
1196
1379
  }
1380
+ /**
1381
+ * The paths of every file below `dirPath`, relative to it, walked rather than
1382
+ * globbed.
1383
+ *
1384
+ * The recursive counterpart of {@link listFileNames}, and there for the same
1385
+ * reason: globby reads a backslash as a path separator and rewrites it in the
1386
+ * paths it returns, so a file named `back\\slash.md` comes back as
1387
+ * `back/slash.md` — a path that belongs to no file, under a name that belongs
1388
+ * to no file either.
1389
+ *
1390
+ * A root that is not there is an empty root. A root that is there but cannot be
1391
+ * read is reported, so it is never mistaken for an empty one — as is a
1392
+ * directory below it, since a subtree silently missing from the result is the
1393
+ * same mistake one level down.
1394
+ *
1395
+ * A directory link is followed like any other directory, but each real
1396
+ * directory is walked only once, so neither a cycle nor a mesh of links can
1397
+ * make the walk repeat itself -- taking every distinct route through a graph of
1398
+ * aliases would cost one traversal per route, which a handful of links is
1399
+ * enough to make hopeless. The name a twice-reachable directory is reported
1400
+ * under is therefore whichever the walk reaches first, and the walk goes level
1401
+ * by level with each level sorted, so that is the shortest path to it and the
1402
+ * first in sorted order among equals. Note the difference from
1403
+ * {@link findFilesByGlobs}, which resolves each of its results and keeps the
1404
+ * real one: here a link nearer the root than the directory it points at stands
1405
+ * in for it. `nameFilter` narrows the files, not the directories the walk
1406
+ * descends into.
1407
+ *
1408
+ * The cost is one round of `readdir` per directory, taken in sequence, and the
1409
+ * whole of a level is held at once, so residency follows the widest level
1410
+ * rather than the deepest path.
1411
+ */
1412
+ async function listFilePathsRecursively(dirPath, options = {}) {
1413
+ const { followSymbolicLinks = true, includeHidden = false, nameFilter, deduplicateByFileIdentity = false } = options;
1414
+ if (!await directoryExists(dirPath)) return [];
1415
+ const filePaths = [];
1416
+ const walkedIdentities = /* @__PURE__ */ new Set();
1417
+ let level = [{
1418
+ currentPath: dirPath,
1419
+ prefix: ""
1420
+ }];
1421
+ while (level.length > 0) {
1422
+ const nextLevel = [];
1423
+ for (const { currentPath, prefix } of level.toSorted((a, b) => a.prefix < b.prefix ? -1 : a.prefix > b.prefix ? 1 : 0)) {
1424
+ const identity = await realFileIdentity(currentPath);
1425
+ if (walkedIdentities.has(identity)) continue;
1426
+ walkedIdentities.add(identity);
1427
+ const [fileNames, dirNames] = await Promise.all([listFileNames(currentPath, {
1428
+ followSymbolicLinks,
1429
+ includeHidden,
1430
+ nameFilter
1431
+ }), listSubdirectoryNames(currentPath, {
1432
+ followSymbolicLinks,
1433
+ includeHidden
1434
+ })]);
1435
+ for (const fileName of fileNames) filePaths.push(prefix === "" ? fileName : join(prefix, fileName));
1436
+ for (const dirName of dirNames) nextLevel.push({
1437
+ currentPath: join(currentPath, dirName),
1438
+ prefix: prefix === "" ? dirName : join(prefix, dirName)
1439
+ });
1440
+ }
1441
+ level = nextLevel;
1442
+ }
1443
+ return deduplicateByFileIdentity ? await deduplicateRelativePathsByFileIdentity({
1444
+ dirPath,
1445
+ relativePaths: filePaths
1446
+ }) : filePaths.toSorted();
1447
+ }
1448
+ /**
1449
+ * One path per real file, chosen the way {@link findFilesByGlobs} chooses it.
1450
+ *
1451
+ * The walk de-duplicates the directories it descends into, so it cannot loop,
1452
+ * but it still reports every name it walks past: a file reached under two names
1453
+ * is listed twice. A caller reading the result as a set of files has to fold
1454
+ * those aliases together, and has to fold them the same way the glob does, or
1455
+ * the two disagree about the name a file has.
1456
+ */
1457
+ async function deduplicateRelativePathsByFileIdentity({ dirPath, relativePaths }) {
1458
+ const candidatesByFile = /* @__PURE__ */ new Map();
1459
+ for (const relativePath of relativePaths.toSorted()) {
1460
+ const absolutePath = join(dirPath, relativePath);
1461
+ const identity = await realFileIdentity(absolutePath);
1462
+ const candidates = candidatesByFile.get(identity);
1463
+ if (candidates === void 0) candidatesByFile.set(identity, [absolutePath]);
1464
+ else candidates.push(absolutePath);
1465
+ }
1466
+ return [...candidatesByFile.entries()].map(([identity, candidates]) => relative(dirPath, chooseRepresentative(candidates, identity))).toSorted();
1467
+ }
1197
1468
  async function removeDirectory(dirPath) {
1198
1469
  if ([
1199
1470
  ".",
@@ -1355,24 +1626,111 @@ var CLIError = class extends Error {
1355
1626
  //#region src/utils/warned-once.ts
1356
1627
  /**
1357
1628
  * The messages a once-per-run warning has already emitted in this process.
1358
- * This lives in its own module, free of imports, so the vitest setup file can
1359
- * clear it between tests without pulling `logger.js` into every test's module
1360
- * graph (which would defeat the module mocks some of those tests install).
1629
+ * This lives in its own module, importing nothing of rulesync's, so the vitest
1630
+ * setup file can clear it between tests without pulling `logger.js` into every
1631
+ * test's module graph (which would defeat the module mocks some of those tests
1632
+ * install).
1361
1633
  */
1362
- const warnedOnceMessages = /* @__PURE__ */ new Set();
1634
+ const processWideMessages = /* @__PURE__ */ new Set();
1635
+ /**
1636
+ * The set an operation that opened its own scope uses instead.
1637
+ *
1638
+ * The MCP server does not serialize requests, so two runs can be in flight at
1639
+ * once. Sharing one set between them would let the first run spend the token
1640
+ * for a message and leave the second one's result silent about a diagnostic
1641
+ * that applies to it just as much. A scope gives each run its own bookkeeping.
1642
+ */
1643
+ const scopedMessages = new AsyncLocalStorage();
1644
+ function currentMessages() {
1645
+ return scopedMessages.getStore() ?? processWideMessages;
1646
+ }
1363
1647
  /** Whether `message` has not been emitted yet; records it when it has not. */
1364
1648
  function claimWarnOnce(message) {
1365
- if (warnedOnceMessages.has(message)) return false;
1366
- warnedOnceMessages.add(message);
1649
+ const messages = currentMessages();
1650
+ if (messages.has(message)) return false;
1651
+ messages.add(message);
1367
1652
  return true;
1368
1653
  }
1369
- /** Forget which warnings were already emitted, so each test starts silent. */
1654
+ /** Forget which warnings were already emitted, so the next run starts silent. */
1370
1655
  function resetWarnedOnceMessages() {
1371
- warnedOnceMessages.clear();
1656
+ currentMessages().clear();
1657
+ }
1658
+ /**
1659
+ * Run `operation` with its own once-per-run bookkeeping, so a concurrent run
1660
+ * neither spends its tokens nor clears its record.
1661
+ */
1662
+ async function withWarnOnceScope(operation) {
1663
+ return await scopedMessages.run(/* @__PURE__ */ new Set(), operation);
1372
1664
  }
1373
1665
  //#endregion
1374
1666
  //#region src/utils/logger.ts
1375
1667
  /**
1668
+ * Formats a log line the way `console.warn` would, so a warning that is handed
1669
+ * back to a caller reads the same as the one that reaches a terminal.
1670
+ */
1671
+ function formatLogLine({ message, args }) {
1672
+ return args.length === 0 ? message : format(message, ...args);
1673
+ }
1674
+ /**
1675
+ * How much a collecting logger keeps: at most this many warnings, each at most
1676
+ * this long, and no more than this in total.
1677
+ *
1678
+ * Collected warnings travel to places a console line does not — a `--json`
1679
+ * document that another program parses, an MCP result that an agent reads as
1680
+ * context — and their text quotes files rulesync did not write. So the amount a
1681
+ * repository can push through has to be bounded twice over: a config with
1682
+ * thousands of odd keys is a plausible accident, and a report sized in hundreds
1683
+ * of kilobytes is a generous budget for text aimed at whoever reads it next.
1684
+ * The total is the binding limit; the per-line and per-count limits keep one
1685
+ * enormous warning, or one enormous number of them, from being the whole of it.
1686
+ */
1687
+ const MAX_COLLECTED_WARNINGS = 100;
1688
+ const MAX_COLLECTED_WARNING_LENGTH = 1e3;
1689
+ const MAX_COLLECTED_TOTAL_LENGTH = 8e3;
1690
+ /**
1691
+ * How many distinct warnings the de-duplication remembers.
1692
+ *
1693
+ * The record has to outlive the reported lines — a line dropped for want of
1694
+ * budget must not be counted again the next time the same diagnostic repeats —
1695
+ * so it grows with the number of distinct warnings a run raises rather than
1696
+ * with the number reported. Bounded for the same reason everything else here
1697
+ * is: past this many, later repeats are counted rather than recognized, which
1698
+ * inflates the trailing count but cannot grow the record without end.
1699
+ */
1700
+ const MAX_DEDUPLICATED_WARNINGS = 1e3;
1701
+ /**
1702
+ * A bounded list of warning lines.
1703
+ */
1704
+ var WarningCollection = class {
1705
+ lines = [];
1706
+ seen = /* @__PURE__ */ new Set();
1707
+ totalLength = 0;
1708
+ omitted = 0;
1709
+ add({ message, args }) {
1710
+ const kept = truncateText({
1711
+ text: stripControlCharacters(formatLogLine({
1712
+ message,
1713
+ args
1714
+ })),
1715
+ maxLength: MAX_COLLECTED_WARNING_LENGTH,
1716
+ suffix: "…(truncated)"
1717
+ });
1718
+ if (this.seen.has(kept)) return;
1719
+ if (this.lines.length >= MAX_COLLECTED_WARNINGS || this.totalLength + kept.length > MAX_COLLECTED_TOTAL_LENGTH) {
1720
+ if (this.seen.size < MAX_DEDUPLICATED_WARNINGS) this.seen.add(kept);
1721
+ this.omitted++;
1722
+ return;
1723
+ }
1724
+ this.seen.add(kept);
1725
+ this.lines.push(kept);
1726
+ this.totalLength += kept.length;
1727
+ }
1728
+ toArray() {
1729
+ if (this.omitted === 0) return [...this.lines];
1730
+ return [...this.lines, `… and ${this.omitted} more warning(s) not reported`];
1731
+ }
1732
+ };
1733
+ /**
1376
1734
  * Base class for shared verbose/silent state and configuration logic
1377
1735
  */
1378
1736
  var BaseLogger = class {
@@ -1388,6 +1746,9 @@ var BaseLogger = class {
1388
1746
  get silent() {
1389
1747
  return this._silent;
1390
1748
  }
1749
+ get reportsWhileSilent() {
1750
+ return false;
1751
+ }
1391
1752
  configure({ verbose, silent }) {
1392
1753
  this._silent = silent;
1393
1754
  this._verbose = verbose && !silent;
@@ -1433,11 +1794,18 @@ var ConsoleLogger = class extends BaseLogger {
1433
1794
  /**
1434
1795
  * JsonLogger - structured JSON output to stdout/stderr
1435
1796
  *
1436
- * All console output methods (info, success, warn, debug) are no-ops.
1797
+ * The console output methods (info, success, debug) are no-ops. `warn` is not:
1798
+ * a diagnostic that only reached the console would be invisible to a `--json`
1799
+ * consumer, which reads the document and nothing else, so warnings are
1800
+ * collected and emitted as the document's top-level `warnings` array instead.
1801
+ * Top-level rather than inside `data` so it can never collide with a key a
1802
+ * command captured, and so it survives on the failure document too — the case
1803
+ * where a diagnostic about the input is most likely to explain the failure.
1437
1804
  */
1438
1805
  var JsonLogger = class extends BaseLogger {
1439
1806
  _jsonOutputDone = false;
1440
1807
  _jsonData = {};
1808
+ _warnings = new WarningCollection();
1441
1809
  _commandName;
1442
1810
  _version;
1443
1811
  constructor({ command, version, verbose = false, silent = false }) {
@@ -1466,6 +1834,8 @@ var JsonLogger = class extends BaseLogger {
1466
1834
  command: this._commandName,
1467
1835
  version: this._version
1468
1836
  };
1837
+ const warnings = this._warnings.toArray();
1838
+ if (warnings.length > 0) output.warnings = warnings;
1469
1839
  if (success) output.data = this._jsonData;
1470
1840
  else if (error) {
1471
1841
  output.error = {
@@ -1481,7 +1851,13 @@ var JsonLogger = class extends BaseLogger {
1481
1851
  }
1482
1852
  info(_message, ..._args) {}
1483
1853
  success(_message, ..._args) {}
1484
- warn(_message, ..._args) {}
1854
+ warn(message, ...args) {
1855
+ if (this._silent) return;
1856
+ this._warnings.add({
1857
+ message,
1858
+ args
1859
+ });
1860
+ }
1485
1861
  error(message, code, ..._args) {
1486
1862
  if (isEnvTest()) return;
1487
1863
  const errorMessage = message instanceof Error ? message.message : message;
@@ -1507,13 +1883,110 @@ function warnOnConflictingFlags({ verbose, silent, jsonMode }) {
1507
1883
  console.warn("Both --verbose and --silent specified; --silent takes precedence");
1508
1884
  }
1509
1885
  /**
1886
+ * Where `fallbackLogger` sends what it is given, scoped to the operation that
1887
+ * adopted it.
1888
+ *
1889
+ * An `AsyncLocalStorage` rather than a plain variable because the target is
1890
+ * per-operation, not per-process: a long-lived MCP server can have two requests
1891
+ * in flight at once, and a save/restore pair would let the first one to finish
1892
+ * hand the still-running request's warnings back to a console nobody reads.
1893
+ * Each operation sees only its own store.
1894
+ */
1895
+ const fallbackTargetStorage = new AsyncLocalStorage();
1896
+ /**
1897
+ * Where warnings go outside any adopted scope: a plain console logger, which is
1898
+ * what a code path with no logger threaded through would otherwise have used.
1899
+ */
1900
+ const defaultFallbackTarget = new ConsoleLogger();
1901
+ function currentFallbackTarget() {
1902
+ return fallbackTargetStorage.getStore() ?? defaultFallbackTarget;
1903
+ }
1904
+ /**
1905
+ * True while the forwarder is inside a call it is forwarding.
1906
+ *
1907
+ * The adopted target is supposed to be something other than the forwarder, but
1908
+ * a wrapper *around* it — `hooks-processor.ts` returns one that prefixes the
1909
+ * tool target onto every warning — passes the identity check in
1910
+ * {@link withFallbackLoggerTarget} and would forward straight back here. A
1911
+ * plain module-level flag is enough because the forwarding is synchronous: the
1912
+ * call returns before anything else can run.
1913
+ */
1914
+ let forwarding = false;
1915
+ /**
1916
+ * Reads from, or writes to, whichever logger the running operation adopted,
1917
+ * with the wrapper case above cut off at one hop.
1918
+ *
1919
+ * Every member of the forwarder goes through here, not just the two that write:
1920
+ * `warnOnceWithFallback` reads `silent` and `reportsWhileSilent` before it ever
1921
+ * calls `warn`, so a guard on the writing side alone would still be reached
1922
+ * through a getter that never returns.
1923
+ */
1924
+ function throughFallbackTarget(use) {
1925
+ if (forwarding) return use(defaultFallbackTarget);
1926
+ forwarding = true;
1927
+ try {
1928
+ return use(currentFallbackTarget());
1929
+ } finally {
1930
+ forwarding = false;
1931
+ }
1932
+ }
1933
+ /**
1510
1934
  * Shared fallback logger for code paths that have no command logger threaded
1511
1935
  * through (module-level translators, `warnWithFallback(undefined, ...)`).
1512
- * `wrapCommand` configures it from CLI flags and `ConfigResolver.resolve`
1513
- * re-configures it from the resolved config, so `silent`/`verbose` settings
1514
- * are honored even on paths where the command logger is not available.
1936
+ *
1937
+ * It is a thin forwarder rather than a logger of its own so that the operation
1938
+ * currently running can adopt it: `wrapCommand` points it at the command
1939
+ * logger, which is how a warning raised deep in a translator still reaches a
1940
+ * `--json` document or an MCP result instead of being written to a console that
1941
+ * nobody in those modes is reading. Modules that captured a reference to
1942
+ * `fallbackLogger` at import time follow the redirection too, which a
1943
+ * swapped-out binding would not give us.
1944
+ */
1945
+ const fallbackLogger = {
1946
+ configure(options) {
1947
+ defaultFallbackTarget.configure(options);
1948
+ },
1949
+ get verbose() {
1950
+ return throughFallbackTarget((target) => target.verbose);
1951
+ },
1952
+ get silent() {
1953
+ return throughFallbackTarget((target) => target.silent);
1954
+ },
1955
+ get reportsWhileSilent() {
1956
+ return throughFallbackTarget((target) => target.reportsWhileSilent);
1957
+ },
1958
+ get jsonMode() {
1959
+ return throughFallbackTarget((target) => target.jsonMode);
1960
+ },
1961
+ captureData(_key, _value) {},
1962
+ getJsonData() {
1963
+ return {};
1964
+ },
1965
+ outputJson(_success, _error) {},
1966
+ info(_message, ..._args) {},
1967
+ success(_message, ..._args) {},
1968
+ warn(message, ...args) {
1969
+ throughFallbackTarget((target) => {
1970
+ target.warn(message, ...args);
1971
+ });
1972
+ },
1973
+ error(message, code, ...args) {
1974
+ defaultFallbackTarget.error(message, code, ...args);
1975
+ },
1976
+ debug(_message, ..._args) {}
1977
+ };
1978
+ /**
1979
+ * Run `operation` with `fallbackLogger` pointed at `logger`, so warnings raised
1980
+ * where no logger was threaded through end up in the same place as the rest of
1981
+ * that operation's diagnostics.
1982
+ *
1983
+ * The redirection lasts exactly as long as the operation and is invisible to
1984
+ * anything running beside it.
1515
1985
  */
1516
- const fallbackLogger = new ConsoleLogger();
1986
+ async function withFallbackLoggerTarget({ logger, operation }) {
1987
+ if (logger === fallbackLogger) return await operation();
1988
+ return await fallbackTargetStorage.run(logger, () => withWarnOnceScope(operation));
1989
+ }
1517
1990
  /**
1518
1991
  * Emit a warning through `logger.warn` if a logger is supplied, otherwise
1519
1992
  * fall through to the shared `fallbackLogger`. Centralizes the "logger may
@@ -1531,9 +2004,35 @@ function warnWithFallback(logger, message) {
1531
2004
  * varies with what the user should do next does not.
1532
2005
  */
1533
2006
  function warnOnceWithFallback(logger, message) {
2007
+ const destination = logger ?? fallbackLogger;
2008
+ if (destination.silent && !destination.reportsWhileSilent) return;
1534
2009
  if (!claimWarnOnce(message)) return;
1535
- warnWithFallback(logger, message);
2010
+ destination.warn(message);
1536
2011
  }
2012
+ /**
2013
+ * A `ConsoleLogger` that keeps the warnings it is given.
2014
+ *
2015
+ * A caller with no console to write to — an MCP tool answering over stdio, where
2016
+ * the server's stderr never reaches the agent — can hand this in and put what
2017
+ * was reported into its own result, so a diagnostic about the files it just read
2018
+ * is something the agent can act on rather than something it never hears.
2019
+ */
2020
+ var WarningCollectingLogger = class extends ConsoleLogger {
2021
+ warnings = new WarningCollection();
2022
+ get reportsWhileSilent() {
2023
+ return true;
2024
+ }
2025
+ warn(message, ...args) {
2026
+ this.warnings.add({
2027
+ message,
2028
+ args
2029
+ });
2030
+ super.warn(message, ...args);
2031
+ }
2032
+ getWarnings() {
2033
+ return this.warnings.toArray();
2034
+ }
2035
+ };
1537
2036
  //#endregion
1538
2037
  //#region src/utils/validation.ts
1539
2038
  /**
@@ -4540,6 +5039,17 @@ function parseStrict(content) {
4540
5039
  return result;
4541
5040
  }
4542
5041
  /**
5042
+ * The error a source document should fail with when
5043
+ * {@link parseJsoncReportingDroppedKeys} reports keys the parser removed.
5044
+ *
5045
+ * Shared by every source that reports them, so the three files a user can
5046
+ * author explain the same removal the same way rather than each inventing its
5047
+ * own wording.
5048
+ */
5049
+ function droppedPollutionKeysError({ sourcePath, droppedKeys }) {
5050
+ 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.`);
5051
+ }
5052
+ /**
4543
5053
  * The same parse as {@link parseJsonc}, additionally reporting which
4544
5054
  * prototype-pollution keys were removed, as dotted paths
4545
5055
  * (`permission.bash.__proto__`).
@@ -4619,9 +5129,17 @@ async function resolveRulesyncSourceWritePath({ outputRoot, paths }) {
4619
5129
  //#region src/features/hooks/rulesync-hooks.ts
4620
5130
  var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
4621
5131
  json;
5132
+ /**
5133
+ * Prototype-pollution keys the parser removed. They are dropped before the
5134
+ * schema ever sees them, so without this record a hook keyed `constructor`
5135
+ * would produce neither an error nor an entry in any generated file.
5136
+ */
5137
+ droppedKeys;
4622
5138
  constructor(params) {
4623
5139
  super({ ...params });
4624
- this.json = parseJsonc(this.fileContent);
5140
+ const { value, droppedKeys } = parseJsoncReportingDroppedKeys({ content: this.fileContent });
5141
+ this.json = value;
5142
+ this.droppedKeys = droppedKeys;
4625
5143
  if (params.validate) {
4626
5144
  const result = this.validate();
4627
5145
  if (!result.success) throw result.error;
@@ -4640,6 +5158,13 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
4640
5158
  };
4641
5159
  }
4642
5160
  validate() {
5161
+ if (this.droppedKeys.length > 0) return {
5162
+ success: false,
5163
+ error: droppedPollutionKeysError({
5164
+ sourcePath: this.getRelativePathFromCwd(),
5165
+ droppedKeys: this.droppedKeys
5166
+ })
5167
+ };
4643
5168
  const result = HooksConfigSchema.safeParse(this.json);
4644
5169
  if (!result.success) return {
4645
5170
  success: false,
@@ -4986,9 +5511,17 @@ function mergeMcpJsonOverlays({ base, overlay }) {
4986
5511
  }
4987
5512
  var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
4988
5513
  json;
5514
+ /**
5515
+ * Prototype-pollution keys the parser removed. They are dropped before the
5516
+ * schema ever sees them, so without this record a server named `__proto__`
5517
+ * would produce neither an error nor an entry in any generated file.
5518
+ */
5519
+ droppedKeys;
4989
5520
  constructor(params) {
4990
5521
  super(params);
4991
- this.json = parseJsonc(this.fileContent);
5522
+ const { value, droppedKeys } = parseJsoncReportingDroppedKeys({ content: this.fileContent });
5523
+ this.json = value;
5524
+ this.droppedKeys = droppedKeys;
4992
5525
  if (params.validate) {
4993
5526
  const result = this.validate();
4994
5527
  if (!result.success) throw result.error;
@@ -5010,6 +5543,13 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
5010
5543
  };
5011
5544
  }
5012
5545
  validate() {
5546
+ if (this.droppedKeys.length > 0) return {
5547
+ success: false,
5548
+ error: droppedPollutionKeysError({
5549
+ sourcePath: this.getRelativePathFromCwd(),
5550
+ droppedKeys: this.droppedKeys
5551
+ })
5552
+ };
5013
5553
  const result = RulesyncMcpFileSchema.safeParse(this.json);
5014
5554
  if (!result.success) return {
5015
5555
  success: false,
@@ -5079,16 +5619,23 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
5079
5619
  }
5080
5620
  const fileContent = await readFileContent(filePath);
5081
5621
  let parsed;
5622
+ let droppedKeys;
5082
5623
  try {
5083
- parsed = parseJsonc(fileContent);
5084
- if (!isRecord$1(parsed)) throw new Error("Expected a JSON object.");
5085
- if (validate) {
5086
- const result = RulesyncMcpFileSchema.safeParse(parsed);
5087
- if (!result.success) throw result.error;
5088
- }
5624
+ const result = parseJsoncReportingDroppedKeys({ content: fileContent });
5625
+ if (!isRecord$1(result.value)) throw new Error("Expected a JSON object.");
5626
+ parsed = result.value;
5627
+ droppedKeys = result.droppedKeys;
5089
5628
  } catch (error) {
5090
5629
  throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
5091
5630
  }
5631
+ if (validate) {
5632
+ if (droppedKeys.length > 0) throw droppedPollutionKeysError({
5633
+ sourcePath: toPosixPath(relative(process.cwd(), filePath)),
5634
+ droppedKeys
5635
+ });
5636
+ const result = RulesyncMcpFileSchema.safeParse(parsed);
5637
+ if (!result.success) throw new Error(`Invalid MCP source file '${filePath}': ${formatError(result.error)}`, { cause: result.error });
5638
+ }
5092
5639
  rootSources.push({
5093
5640
  record: parsed,
5094
5641
  outputRoot: parent,
@@ -5334,12 +5881,13 @@ const PermissionActionSchema = z.enum([
5334
5881
  "deny"
5335
5882
  ]);
5336
5883
  /**
5337
- * Whether a permission pattern is blankempty, or only whitespace. Shared
5338
- * with the import-side filter so the key the schema rejects and the key that
5339
- * filter removes can never drift apart.
5884
+ * Whether a key in a permission block a category name or a pattern — is
5885
+ * blank, that is empty or only whitespace. Shared with the import-side filter
5886
+ * so the key the schema rejects and the key that filter removes can never
5887
+ * drift apart.
5340
5888
  */
5341
- function isBlankPermissionPattern(pattern) {
5342
- return pattern.trim().length === 0;
5889
+ function isBlankPermissionKey(key) {
5890
+ return key.trim().length === 0;
5343
5891
  }
5344
5892
  /**
5345
5893
  * A single permission pattern key.
@@ -5351,7 +5899,18 @@ function isBlankPermissionPattern(pattern) {
5351
5899
  * silently ignores it. Rather than let each target decide, reject it here so
5352
5900
  * the mistake surfaces once, on the source file.
5353
5901
  */
5354
- const PermissionPatternSchema = z.string().check(z.refine((pattern) => !isBlankPermissionPattern(pattern), { message: "Permission pattern must not be blank" }));
5902
+ const PermissionPatternSchema = z.string().check(z.refine((pattern) => !isBlankPermissionKey(pattern), { message: "Permission pattern must not be blank" }));
5903
+ /**
5904
+ * A permission category key: the name of the tool surface a rules map applies
5905
+ * to (`bash`, `edit`, `webfetch`, ...).
5906
+ *
5907
+ * Blank is rejected for the same reason a blank pattern is, one step up. Every
5908
+ * translator reads categories by name, so `{"": {"git *": "allow"}}` reaches no
5909
+ * tool at all and the rules under it are silently dead — the mistake is only
5910
+ * visible as an entry missing from a generated config. Rejecting it here
5911
+ * surfaces it on the source file instead.
5912
+ */
5913
+ const PermissionCategorySchema = z.string().check(z.refine((category) => !isBlankPermissionKey(category), { message: "Permission category must not be blank" }));
5355
5914
  /**
5356
5915
  * Permission rules for a single tool category.
5357
5916
  * Keys are glob patterns matching tool input (commands, file paths, etc.).
@@ -5372,7 +5931,7 @@ const PermissionRulesSchema = z.record(PermissionPatternSchema, PermissionAction
5372
5931
  * @example
5373
5932
  * { "claudecode": { "permission": { "bash": { "git push *": "deny" } } } }
5374
5933
  */
5375
- const ToolScopedPermissionSchema = z.record(z.string(), PermissionRulesSchema);
5934
+ const ToolScopedPermissionSchema = z.record(PermissionCategorySchema, PermissionRulesSchema);
5376
5935
  /**
5377
5936
  * Generic tool-scoped override block for tools that have no tool-specific
5378
5937
  * override keys of their own; it carries only the canonical tool-scoped
@@ -5430,7 +5989,7 @@ const OpencodeOverridePermissionValueSchema = z.union([PermissionActionSchema, P
5430
5989
  * @example
5431
5990
  * { "permission": { "external_directory": "deny", "webfetch": "allow" } }
5432
5991
  */
5433
- const OpencodePermissionsOverrideSchema = z.looseObject({ permission: z.optional(z.record(z.string(), OpencodeOverridePermissionValueSchema)) });
5992
+ const OpencodePermissionsOverrideSchema = z.looseObject({ permission: z.optional(z.record(PermissionCategorySchema, OpencodeOverridePermissionValueSchema)) });
5434
5993
  /**
5435
5994
  * Tool-scoped override block for Hermes Agent. Keys placed here are deep-merged
5436
5995
  * into Hermes's `~/.hermes/config.yaml` and never leak into other tools' configs.
@@ -5490,7 +6049,7 @@ const ClinePermissionsOverrideSchema = z.looseObject({
5490
6049
  * @see https://kilo.ai/docs/getting-started/settings/sandboxing
5491
6050
  */
5492
6051
  const KiloPermissionsOverrideSchema = z.looseObject({
5493
- permission: z.optional(z.record(z.string(), OpencodeOverridePermissionValueSchema)),
6052
+ permission: z.optional(z.record(PermissionCategorySchema, OpencodeOverridePermissionValueSchema)),
5494
6053
  sandbox: z.optional(z.looseObject({}))
5495
6054
  });
5496
6055
  /**
@@ -5561,7 +6120,7 @@ const ClaudecodePermissionsOverrideSchema = z.looseObject({
5561
6120
  * { "enabled_tools": ["bash", "read_file", "grep"] }
5562
6121
  */
5563
6122
  const VibePermissionsOverrideSchema = z.looseObject({
5564
- permission: z.optional(z.record(z.string(), z.looseObject({ sensitive_patterns: z.optional(z.array(z.string())) }))),
6123
+ permission: z.optional(z.record(PermissionCategorySchema, z.looseObject({ sensitive_patterns: z.optional(z.array(z.string())) }))),
5565
6124
  enabled_tools: z.optional(z.array(z.string()))
5566
6125
  });
5567
6126
  /**
@@ -6295,12 +6854,12 @@ const ZedPermissionsOverrideSchema = z.looseObject({
6295
6854
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
6296
6855
  * Values are pattern-to-action mappings for that tool category.
6297
6856
  *
6298
- * The optional `opencode`/`hermes`/`cline`/`kilo`/`claudecode`/`vibe`/`cursor`/
6299
- * `qwencode`/`reasonix`/`factorydroid`/`warp`/`junie`/`takt`/`amp`/
6300
- * `antigravity-cli`/`augmentcode`/`kiro`/`codexcli`/`zed` keys are tool-scoped
6301
- * overrides consumed only by their respective translator (see the matching
6302
- * `*PermissionsOverrideSchema`); every other tool reads the shared `permission`
6303
- * block and ignores them.
6857
+ * The optional tool keys below are tool-scoped overrides consumed only by their
6858
+ * respective translator (see the matching `*PermissionsOverrideSchema`); every
6859
+ * other tool reads the shared `permission` block and ignores them. The set of
6860
+ * keys is exactly `permissionsProcessorToolTargetTuple` mapped through
6861
+ * `PERMISSION_OVERRIDE_KEY_ALIASES` a test asserts that, so this comment does
6862
+ * not enumerate them and go stale.
6304
6863
  *
6305
6864
  * Additionally, every permissions-capable tool accepts a canonical tool-scoped
6306
6865
  * `permission` block under its override key (`{toolname}.permission`, same
@@ -6318,7 +6877,7 @@ const ZedPermissionsOverrideSchema = z.looseObject({
6318
6877
  * }
6319
6878
  */
6320
6879
  const PermissionsConfigSchema = z.looseObject({
6321
- permission: z.record(z.string(), PermissionRulesSchema),
6880
+ permission: z.record(PermissionCategorySchema, PermissionRulesSchema),
6322
6881
  opencode: z.optional(OpencodePermissionsOverrideSchema),
6323
6882
  hermes: z.optional(HermesPermissionsOverrideSchema),
6324
6883
  cline: z.optional(ClinePermissionsOverrideSchema),
@@ -6347,7 +6906,9 @@ const PermissionsConfigSchema = z.looseObject({
6347
6906
  goose: z.optional(CanonicalPermissionsOverrideSchema),
6348
6907
  grokcli: z.optional(CanonicalPermissionsOverrideSchema),
6349
6908
  "kimi-code": z.optional(KimiCodePermissionsOverrideSchema),
6350
- rovodev: z.optional(CanonicalPermissionsOverrideSchema)
6909
+ roo: z.optional(CanonicalPermissionsOverrideSchema),
6910
+ rovodev: z.optional(CanonicalPermissionsOverrideSchema),
6911
+ zoocode: z.optional(CanonicalPermissionsOverrideSchema)
6351
6912
  });
6352
6913
  /**
6353
6914
  * Full permissions file schema including optional $schema field.
@@ -6389,10 +6950,40 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
6389
6950
  }]
6390
6951
  };
6391
6952
  }
6953
+ /**
6954
+ * The canonical document an importer produces from a tool's own config.
6955
+ *
6956
+ * Every importer has to run the blank-key filter over what it is about to
6957
+ * write: the canonical schema rejects a blank pattern and a blank category
6958
+ * outright, so a source file carrying either would be refused by the very
6959
+ * next `generate` — and that refusal takes the whole file with it, so one
6960
+ * blank key imported from one tool stops every tool's permissions from being
6961
+ * generated. Building the imported document here rather than calling the
6962
+ * filter beside each `new RulesyncPermissions(...)` is what keeps the next
6963
+ * importer from forgetting it.
6964
+ *
6965
+ * `sourcePath` is the tool config being read, used only to name it if
6966
+ * something is dropped.
6967
+ */
6968
+ static fromImportedFileContent({ outputRoot, fileContent, sourcePath, logger }) {
6969
+ return new RulesyncPermissions({
6970
+ outputRoot,
6971
+ relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
6972
+ relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
6973
+ fileContent: withoutBlankPermissionKeys({
6974
+ fileContent,
6975
+ sourcePath,
6976
+ logger
6977
+ })
6978
+ });
6979
+ }
6392
6980
  validate() {
6393
6981
  if (this.droppedKeys.length > 0) return {
6394
6982
  success: false,
6395
- error: /* @__PURE__ */ new Error(`${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.`)
6983
+ error: droppedPollutionKeysError({
6984
+ sourcePath: this.getRelativePathFromCwd(),
6985
+ droppedKeys: this.droppedKeys
6986
+ })
6396
6987
  };
6397
6988
  const result = RulesyncPermissionsFileSchema.safeParse(this.json);
6398
6989
  if (!result.success) return {
@@ -6464,33 +7055,62 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
6464
7055
  }
6465
7056
  };
6466
7057
  /**
6467
- * Strip every blank permission pattern from an already-parsed canonical
6468
- * document, reporting how many were dropped from each block.
7058
+ * Tool-scoped override keys whose `permission` block maps a category to
7059
+ * something other than a pattern map. Vibe alone keeps
7060
+ * `{ sensitive_patterns: [...] }` objects there, so the keys one level down are
7061
+ * field names and the blank-pattern filter must not walk them. The category
7062
+ * names above them are still category names, and are filtered like any other.
7063
+ *
7064
+ * Typed as `ToolTarget` so a renamed target fails to compile here rather than
7065
+ * silently stopping to match, which would let the filter start deleting Vibe's
7066
+ * fields and reporting them as removed permission patterns. That only holds for
7067
+ * targets whose override key is the target name itself: a target that aliases
7068
+ * to another key (see `PERMISSION_OVERRIDE_KEY_ALIASES`) would have to be listed
7069
+ * under the alias, which this type would reject. None of them is non-pattern-map
7070
+ * today, so add that spelling only when one becomes so.
7071
+ */
7072
+ const NON_PATTERN_MAP_PERMISSION_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["vibe"]);
7073
+ /**
7074
+ * Strip every blank key — category or pattern — from an already-parsed
7075
+ * canonical document, reporting how many were dropped from each block.
6469
7076
  *
6470
7077
  * Both the shared `permission` block and every tool-scoped
6471
7078
  * `{toolname}.permission` block are walked, because import produces both:
6472
7079
  * OpenCode and Kilo route their tool-only categories into the tool-scoped block
6473
- * verbatim, so a blank pattern in the user's own config lands there. A category
6474
- * whose value is not a rules map is left exactly as it is, which is what keeps
6475
- * the tool-native shapes intact — OpenCode's and Kilo's bare action strings
6476
- * (`"external_directory": "deny"`) have no pattern key to inspect, Vibe's
6477
- * `sensitive_patterns` objects carry no blank key, and Kilo's `sandbox` is not a
6478
- * `permission` block at all.
6479
- */
6480
- function stripBlankPermissionPatterns(config) {
6481
- const removed = /* @__PURE__ */ new Map();
6482
- const filterBlock = ({ block, blockPath }) => {
7080
+ * verbatim, so a blank key in the user's own config lands there. A category
7081
+ * whose value is not a rules map keeps its value exactly as it is, which is what
7082
+ * keeps the tool-native shapes intact — OpenCode's and Kilo's bare action
7083
+ * strings (`"external_directory": "deny"`) have no pattern key to inspect, and
7084
+ * Kilo's `sandbox` is not a `permission` block at all.
7085
+ *
7086
+ * Categories are filtered for the same reason patterns are, one level up: the
7087
+ * canonical schema rejects a blank category, so reproducing one would write a
7088
+ * source file the very next `generate` refuses — and it would refuse the whole
7089
+ * file, taking every tool's permissions generation down with it.
7090
+ *
7091
+ * The patterns inside {@link NON_PATTERN_MAP_PERMISSION_OVERRIDE_KEYS} blocks
7092
+ * are left alone: they are field names rather than patterns there. Their
7093
+ * category names are still filtered.
7094
+ */
7095
+ function stripBlankPermissionKeys(config) {
7096
+ const patterns = /* @__PURE__ */ new Map();
7097
+ const categories = /* @__PURE__ */ new Map();
7098
+ const filterBlock = ({ block, blockPath, filterPatterns }) => {
6483
7099
  const filtered = {};
6484
7100
  for (const [category, rules] of Object.entries(block)) {
6485
- if (!isRecord$1(rules)) {
7101
+ if (isBlankPermissionKey(category)) {
7102
+ categories.set(blockPath, (categories.get(blockPath) ?? 0) + 1);
7103
+ continue;
7104
+ }
7105
+ if (!filterPatterns || !isRecord$1(rules)) {
6486
7106
  filtered[category] = rules;
6487
7107
  continue;
6488
7108
  }
6489
7109
  const kept = {};
6490
7110
  for (const [pattern, action] of Object.entries(rules)) {
6491
- if (isBlankPermissionPattern(pattern)) {
7111
+ if (isBlankPermissionKey(pattern)) {
6492
7112
  const path = `${blockPath}.${category}`;
6493
- removed.set(path, (removed.get(path) ?? 0) + 1);
7113
+ patterns.set(path, (patterns.get(path) ?? 0) + 1);
6494
7114
  continue;
6495
7115
  }
6496
7116
  kept[pattern] = action;
@@ -6503,25 +7123,41 @@ function stripBlankPermissionPatterns(config) {
6503
7123
  const next = { ...config };
6504
7124
  if (isRecord$1(config.permission)) next.permission = filterBlock({
6505
7125
  block: config.permission,
6506
- blockPath: "permission"
7126
+ blockPath: "permission",
7127
+ filterPatterns: true
6507
7128
  });
6508
7129
  for (const [key, value] of Object.entries(config)) {
6509
7130
  if (key === "permission" || !isRecord$1(value) || !isRecord$1(value.permission)) continue;
6510
- next[key] = {
6511
- ...value,
6512
- permission: filterBlock({
6513
- block: value.permission,
6514
- blockPath: `${key}.permission`
6515
- })
6516
- };
7131
+ const permission = filterBlock({
7132
+ block: value.permission,
7133
+ blockPath: `${key}.permission`,
7134
+ filterPatterns: !NON_PATTERN_MAP_PERMISSION_OVERRIDE_KEYS.has(key)
7135
+ });
7136
+ if (!(Object.keys(permission).length === 0 && Object.keys(value.permission).length > 0)) {
7137
+ next[key] = {
7138
+ ...value,
7139
+ permission
7140
+ };
7141
+ continue;
7142
+ }
7143
+ const { permission: _emptied, ...rest } = value;
7144
+ if (Object.keys(rest).length === 0) {
7145
+ delete next[key];
7146
+ continue;
7147
+ }
7148
+ next[key] = rest;
6517
7149
  }
6518
7150
  return {
6519
7151
  config: next,
6520
- removed
7152
+ removed: {
7153
+ patterns,
7154
+ categories
7155
+ }
6521
7156
  };
6522
7157
  }
7158
+ const summarizeDroppedCounts = (counts) => [...counts.entries()].map(([path, count]) => `${count} in ${JSON.stringify(path)}`).join(", ");
6523
7159
  /**
6524
- * Report the dropped patterns.
7160
+ * Report the dropped keys.
6525
7161
  *
6526
7162
  * Dropping an entry silently is the failure mode a permissions source must not
6527
7163
  * have. A blanket blank pattern can read as "deny everything by default";
@@ -6532,27 +7168,36 @@ function stripBlankPermissionPatterns(config) {
6532
7168
  * `logger` is optional because the import direction (`toRulesyncPermissions`)
6533
7169
  * takes no logger parameter; the shared `fallbackLogger` is configured from the
6534
7170
  * CLI flags and the resolved config, so `silent` is still honored.
7171
+ *
7172
+ * `sourcePath` names the tool config the keys came out of. A single import run
7173
+ * reads many tools, and the block paths alone (`permission.bash`) are the same
7174
+ * for all of them, so without it the user is told something was dropped but not
7175
+ * from where.
6535
7176
  */
6536
- function warnAboutDroppedPatterns({ removed, logger }) {
6537
- 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.`);
7177
+ function warnAboutDroppedKeys({ removed, sourcePath, logger }) {
7178
+ const source = sourcePath === void 0 ? "a tool's permission configuration" : JSON.stringify(sourcePath);
7179
+ 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.`);
7180
+ 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.`);
6538
7181
  }
6539
7182
  /**
6540
- * Drop blank permission patterns from a canonical document produced by import.
7183
+ * Drop blank permission keys from a canonical document produced by import.
6541
7184
  *
6542
- * The canonical schema rejects a blank pattern outright, and every tool that
6543
- * has one in its own config already treats it as something other than a real
6544
- * pattern (Roo Code, for instance, keeps only entries passing
6545
- * `cmd.trim().length > 0`). Reproducing one in `.rulesync/permissions.jsonc`
6546
- * would therefore write a source file that the very next `generate` refuses —
6547
- * so it is removed here instead, and reported.
7185
+ * The canonical schema rejects a blank pattern and a blank category outright,
7186
+ * and every tool that has a blank pattern in its own config already treats it as
7187
+ * something other than a real pattern (Roo Code, for instance, keeps only
7188
+ * entries passing `cmd.trim().length > 0`). Reproducing either in
7189
+ * `.rulesync/permissions.jsonc` would therefore write a source file that the
7190
+ * very next `generate` refuses the whole file, not just that entry — so they
7191
+ * are removed here instead, and reported.
6548
7192
  */
6549
- function withoutBlankPermissionPatterns({ fileContent, logger }) {
7193
+ function withoutBlankPermissionKeys({ fileContent, sourcePath, logger }) {
6550
7194
  const parsed = parseJsonc(fileContent);
6551
7195
  if (!isRecord$1(parsed)) return fileContent;
6552
- const { config, removed } = stripBlankPermissionPatterns(parsed);
6553
- if (removed.size === 0) return fileContent;
6554
- warnAboutDroppedPatterns({
7196
+ const { config, removed } = stripBlankPermissionKeys(parsed);
7197
+ if (removed.patterns.size === 0 && removed.categories.size === 0) return fileContent;
7198
+ warnAboutDroppedKeys({
6555
7199
  removed,
7200
+ sourcePath,
6556
7201
  logger
6557
7202
  });
6558
7203
  return JSON.stringify(config, null, 2);
@@ -6561,14 +7206,15 @@ function withoutBlankPermissionPatterns({ fileContent, logger }) {
6561
7206
  * The same filter over an already-parsed document, for callers that validate a
6562
7207
  * canonical block before it is ever serialized. Hermes Agent stores its
6563
7208
  * rulesync provenance inside its own config and parses it back on import; left
6564
- * unfiltered, one blank pattern would fail `safeParse` and discard the entire
6565
- * provenance block without a word.
7209
+ * unfiltered, one blank pattern or category would fail `safeParse` and discard
7210
+ * the entire provenance block without a word.
6566
7211
  */
6567
- function withoutBlankPermissionPatternsIn({ config, logger }) {
6568
- const { config: filtered, removed } = stripBlankPermissionPatterns(config);
6569
- if (removed.size === 0) return config;
6570
- warnAboutDroppedPatterns({
7212
+ function withoutBlankPermissionKeysIn({ config, sourcePath, logger }) {
7213
+ const { config: filtered, removed } = stripBlankPermissionKeys(config);
7214
+ if (removed.patterns.size === 0 && removed.categories.size === 0) return config;
7215
+ warnAboutDroppedKeys({
6571
7216
  removed,
7217
+ sourcePath,
6572
7218
  logger
6573
7219
  });
6574
7220
  return filtered;
@@ -6717,6 +7363,24 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
6717
7363
  //#endregion
6718
7364
  //#region src/types/ai-dir.ts
6719
7365
  /**
7366
+ * Whether `name` is a path rather than a single name.
7367
+ *
7368
+ * Both separators are rejected on every platform, which is why neither
7369
+ * `path.sep` nor the platform is consulted: a backslash is a legal character in
7370
+ * a POSIX name, but a name carrying one is a path the moment it reaches
7371
+ * Windows, and `AiDir` names travel between the two — most tools take one
7372
+ * straight from a skill's frontmatter.
7373
+ *
7374
+ * Exported because the checks that run *before* a name reaches `AiDir` — so
7375
+ * that an unusable name is reported and skipped rather than thrown over — have
7376
+ * to reject exactly the set this guard does. Two spellings of one rule drift
7377
+ * apart the moment either is tightened, and the pre-filter drifting narrower
7378
+ * turns a reported name back into a failed run.
7379
+ */
7380
+ function containsPathSeparator(name) {
7381
+ return name.includes("/") || name.includes("\\");
7382
+ }
7383
+ /**
6720
7384
  * Directories that hold credentials. Excluding these protects something, so
6721
7385
  * their exclusion is reported rather than silent.
6722
7386
  */
@@ -7176,7 +7840,7 @@ var AiDir = class AiDir {
7176
7840
  */
7177
7841
  global;
7178
7842
  constructor({ outputRoot = process.cwd(), relativeDirPath, dirName, mainFile, otherFiles = [], global = false }) {
7179
- if (dirName.includes(path.sep) || dirName.includes("/") || dirName.includes("\\")) throw new Error(`Directory name cannot contain path separators: dirName="${dirName}"`);
7843
+ if (containsPathSeparator(dirName)) throw new Error(`Directory name cannot contain path separators: dirName="${dirName}"`);
7180
7844
  if (dirName === "" || dirName === "." || dirName === "..") throw new Error(`Directory name cannot be empty, ".", or "..": dirName="${dirName}"`);
7181
7845
  this.outputRoot = outputRoot;
7182
7846
  this.relativeDirPath = relativeDirPath;
@@ -7237,7 +7901,7 @@ var AiDir = class AiDir {
7237
7901
  const mainFile = this.getMainFile();
7238
7902
  if (mainFile === void 0) return;
7239
7903
  const name = mainFile.name;
7240
- if (name === "" || name === "." || name === ".." || name.includes("/") || name.includes("\\")) return;
7904
+ if (name === "" || name === "." || name === ".." || containsPathSeparator(name)) return;
7241
7905
  return path.join(this.getDirPath(), name);
7242
7906
  }
7243
7907
  getDirPath() {
@@ -7568,7 +8232,7 @@ var AiDir = class AiDir {
7568
8232
  * differ from the path the run claims, which turns it into an orphan.
7569
8233
  */
7570
8234
  function isUnsafeSkillDirName(name) {
7571
- return name === "" || name === "." || name === ".." || name.includes("/") || name.includes("\\") || name.endsWith(".") || name.endsWith(" ");
8235
+ return name === "" || name === "." || name === ".." || containsPathSeparator(name) || name.endsWith(".") || name.endsWith(" ");
7572
8236
  }
7573
8237
  const RulesyncSkillFrontmatterSchema = z.looseObject({
7574
8238
  name: z.string().check(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" })),
@@ -7828,6 +8492,16 @@ const RulesyncSubagentFrontmatterSchema = z.looseObject({
7828
8492
  zoocode: z.optional(z.looseObject({
7829
8493
  /** Per-mode MCP server allowlist (Zoo Code v3.60.0+); omitted = all. */
7830
8494
  allowedMcpServers: z.optional(z.array(z.string())) })),
8495
+ zcode: z.optional(z.looseObject({
8496
+ model: z.optional(z.string()),
8497
+ thoughtLevel: z.optional(z.string()),
8498
+ color: z.optional(z.string()),
8499
+ tools: z.optional(z.array(z.string())),
8500
+ disallowedTools: z.optional(z.array(z.string())),
8501
+ maxTurns: z.optional(z.number().check(z.int(), z.positive())),
8502
+ injectAgentsMd: z.optional(z.boolean()),
8503
+ mcpServers: z.optional(z.array(z.string()))
8504
+ })),
7831
8505
  vibe: z.optional(z.looseObject({
7832
8506
  agent_type: z.optional(z.enum(["agent", "subagent"])),
7833
8507
  display_name: z.optional(z.string()),
@@ -7915,10 +8589,12 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
7915
8589
  * lets a directory be *created* with a backslash in its name, so such a
7916
8590
  * directory can sit in a skills root that nothing here can name — it is
7917
8591
  * reported rather than passed on, since the alternative is a candidate built
7918
- * from a name that belongs to no directory at all.
8592
+ * from a name that belongs to no directory at all. The test is `AiDir`'s own,
8593
+ * so this pre-filter cannot come to accept a name the guard behind it throws
8594
+ * over.
7919
8595
  */
7920
8596
  function isAddressableSkillName(name) {
7921
- return !name.includes("/") && !name.includes("\\");
8597
+ return !containsPathSeparator(name);
7922
8598
  }
7923
8599
  /**
7924
8600
  * The names of the skill directories directly under `skillsRoot`.
@@ -9309,18 +9985,168 @@ function splitCheckFile({ fileContent, fallbackName }) {
9309
9985
  });
9310
9986
  }
9311
9987
  //#endregion
9988
+ //#region src/features/checks/aggregated-tool-check.ts
9989
+ /**
9990
+ * Shared skeleton for the checks adapters whose output is a single aggregated
9991
+ * instruction file — one file whose sections are the marked blocks
9992
+ * `aggregated-check-file.ts` renders and splits, rather than a directory with a
9993
+ * file per check.
9994
+ *
9995
+ * That module already held the rendering and the splitting; what is here is the
9996
+ * adapter around it, which was near-verbatim in Cursor Bugbot, Rovo Dev and
9997
+ * Factory Droid. A subclass supplies {@link ToolCheck.getSettablePaths} and
9998
+ * {@link getAggregatedCheckConfig}, and inherits the rest — so a fix to how
9999
+ * these files are read or written lands once instead of three times.
10000
+ *
10001
+ * `fromRulesyncCheck` is refused here rather than implemented: sections share
10002
+ * one file, so an output cannot be produced from one check in isolation and the
10003
+ * processor calls {@link fromRulesyncChecks} instead.
10004
+ *
10005
+ * Not declared `abstract`, even though nothing instantiates it directly: the
10006
+ * statics below build the subclass with `new this(...)`, which an abstract
10007
+ * constructor type forbids. What a subclass owes is enforced the way the rest
10008
+ * of this codebase enforces it on a static — {@link getAggregatedCheckConfig}
10009
+ * throws until it is overridden.
10010
+ */
10011
+ var AggregatedToolCheck = class extends ToolCheck {
10012
+ /**
10013
+ * The per-tool values the shared skeleton reads. Thrown rather than abstract
10014
+ * because TypeScript has no abstract statics; a subclass that forgets it
10015
+ * fails on its first use rather than silently taking a default.
10016
+ */
10017
+ static getAggregatedCheckConfig() {
10018
+ throw new Error("Please implement this method in the subclass.");
10019
+ }
10020
+ /**
10021
+ * The settable paths with the file name required. An aggregated adapter names
10022
+ * the one file it writes — that is what keeps consumers which would otherwise
10023
+ * claim the whole tool directory, the gitignore derivation among them,
10024
+ * narrowed to it — so a missing name is a mistake in the subclass rather than
10025
+ * a case to fall back for.
10026
+ */
10027
+ static getAggregatedPaths({ global = false } = {}) {
10028
+ const paths = this.getSettablePaths({ global });
10029
+ if (!paths.relativeFilePath) throw new Error(`${this.name} writes one aggregated file, so getSettablePaths must name it.`);
10030
+ return {
10031
+ relativeDirPath: paths.relativeDirPath,
10032
+ relativeFilePath: paths.relativeFilePath
10033
+ };
10034
+ }
10035
+ static getAggregatedFilePath({ outputRoot, global = false }) {
10036
+ const paths = this.getAggregatedPaths({ global });
10037
+ return {
10038
+ ...paths,
10039
+ filePath: join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)
10040
+ };
10041
+ }
10042
+ static isTargetedByRulesyncCheck(rulesyncCheck) {
10043
+ return this.isTargetedByRulesyncCheckDefault({
10044
+ rulesyncCheck,
10045
+ toolTarget: this.getAggregatedCheckConfig().toolTarget
10046
+ });
10047
+ }
10048
+ /**
10049
+ * Ownership guard the processor consults before it deletes anything for this
10050
+ * tool. Every one of these paths is one a user may well have written by hand,
10051
+ * whether because the tool documents it as the place to write review
10052
+ * instructions or because it is shared with something else the tool loads, so
10053
+ * anything in it that rulesync did not write is not rulesync's to remove — dropping the last check targeting the tool must not
10054
+ * take somebody's review instructions with it. Deletion is therefore allowed
10055
+ * only for a file that is nothing but generated sections: one that carries no
10056
+ * marker at all, or that carries hand-written text ahead of the first marker,
10057
+ * stays.
10058
+ */
10059
+ static async canDeleteAuxiliaryFiles({ outputRoot }) {
10060
+ const { filePath } = this.getAggregatedFilePath({ outputRoot });
10061
+ const fileContent = await readFileContentOrNull(filePath);
10062
+ if (fileContent === null) return true;
10063
+ return isOnlyGeneratedSections(fileContent);
10064
+ }
10065
+ static fromRulesyncCheck(_params) {
10066
+ const { displayName } = this.getAggregatedCheckConfig();
10067
+ throw new Error(`${displayName} checks are built from all checks at once; use fromRulesyncChecks.`);
10068
+ }
10069
+ static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
10070
+ if (rulesyncChecks.length === 0) return [];
10071
+ const config = this.getAggregatedCheckConfig();
10072
+ const { relativeDirPath, relativeFilePath, filePath } = this.getAggregatedFilePath({
10073
+ outputRoot,
10074
+ global
10075
+ });
10076
+ if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) {
10077
+ if (config.handWrittenPreamble === "skip") {
10078
+ logger?.warn(config.handWrittenWarning({
10079
+ filePath,
10080
+ displayName: config.displayName,
10081
+ toolTarget: config.toolTarget
10082
+ }));
10083
+ return [];
10084
+ }
10085
+ 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.`);
10086
+ }
10087
+ return [new this({
10088
+ outputRoot,
10089
+ relativeDirPath,
10090
+ relativeFilePath,
10091
+ fileContent: renderCheckFile(rulesyncChecks),
10092
+ global
10093
+ })];
10094
+ }
10095
+ static async fromFile({ outputRoot = process.cwd(), global = false }) {
10096
+ const { relativeDirPath, relativeFilePath, filePath } = this.getAggregatedFilePath({
10097
+ outputRoot,
10098
+ global
10099
+ });
10100
+ return new this({
10101
+ outputRoot,
10102
+ relativeDirPath,
10103
+ relativeFilePath,
10104
+ fileContent: await readFileContentOrNull(filePath) ?? "",
10105
+ global
10106
+ });
10107
+ }
10108
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
10109
+ return new this({
10110
+ outputRoot,
10111
+ relativeDirPath,
10112
+ relativeFilePath,
10113
+ fileContent: "",
10114
+ validate: false,
10115
+ global
10116
+ });
10117
+ }
10118
+ validate() {
10119
+ return {
10120
+ success: true,
10121
+ error: null
10122
+ };
10123
+ }
10124
+ toRulesyncCheck() {
10125
+ const first = this.toRulesyncChecks()[0];
10126
+ if (!first) throw new Error(`No check instructions found in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
10127
+ return first;
10128
+ }
10129
+ toRulesyncChecks() {
10130
+ const config = this.constructor.getAggregatedCheckConfig();
10131
+ const fileContent = this.getFileContent();
10132
+ return splitCheckFile({
10133
+ fileContent: config.transformImportedContent?.(fileContent) ?? fileContent,
10134
+ fallbackName: config.fallbackCheckName
10135
+ });
10136
+ }
10137
+ };
10138
+ //#endregion
9312
10139
  //#region src/features/checks/cursor-check.ts
9313
- const FALLBACK_CHECK_NAME$2 = "bugbot";
9314
10140
  /**
9315
10141
  * Checks adapter for Cursor Bugbot (`.cursor/BUGBOT.md`).
9316
10142
  *
9317
10143
  * Bugbot takes one aggregated instruction file per directory rather than a file
9318
10144
  * per check, so every `.rulesync/checks/*.md` targeting Cursor collapses into
9319
- * the repository-root `.cursor/BUGBOT.md` — hence {@link fromRulesyncChecks}
9320
- * rather than the usual per-check conversion. Each check becomes one section:
9321
- * an HTML-comment marker carrying the check name, an `## <name>` heading, and
9322
- * the check body as the instruction text (the `description` is used when the
9323
- * body is empty).
10145
+ * the repository-root `.cursor/BUGBOT.md` — hence `fromRulesyncChecks` rather
10146
+ * than the usual per-check conversion, which {@link AggregatedToolCheck}
10147
+ * provides. Each check becomes one section: an HTML-comment marker carrying the
10148
+ * check name, an `## <name>` heading, and the check body as the instruction
10149
+ * text (the `description` is used when the body is empty).
9324
10150
  *
9325
10151
  * Bugbot reads the file as free prose, so a check's `severity` and `tools` have
9326
10152
  * no equivalent there: they are not written and do not come back on import. So
@@ -9336,97 +10162,26 @@ const FALLBACK_CHECK_NAME$2 = "bugbot";
9336
10162
  * before the first marker — and a hand-written file with no markers at all —
9337
10163
  * becomes a single `bugbot` check, so nothing in the file is dropped. A file
9338
10164
  * holding anything rulesync did not write is never deleted either (see
9339
- * {@link canDeleteAuxiliaryFiles}), though generating checks for Cursor does
9340
- * replace it — import first to keep what is there, which is warned about.
10165
+ * `canDeleteAuxiliaryFiles` on the base), though generating checks for Cursor
10166
+ * does replace it — import first to keep what is there, which is warned about.
9341
10167
  *
9342
10168
  * @see https://cursor.com/docs/bugbot
9343
10169
  */
9344
- var CursorCheck = class CursorCheck extends ToolCheck {
10170
+ var CursorCheck = class extends AggregatedToolCheck {
9345
10171
  static getSettablePaths(_options = {}) {
9346
10172
  return {
9347
10173
  relativeDirPath: CURSOR_DIR,
9348
10174
  relativeFilePath: CURSOR_BUGBOT_FILE_NAME
9349
10175
  };
9350
10176
  }
9351
- static isTargetedByRulesyncCheck(rulesyncCheck) {
9352
- return this.isTargetedByRulesyncCheckDefault({
9353
- rulesyncCheck,
9354
- toolTarget: "cursor"
9355
- });
9356
- }
9357
- /**
9358
- * Ownership guard the processor consults before it deletes anything for this
9359
- * tool. `.cursor/BUGBOT.md` is a file Cursor's own documentation tells users
9360
- * to hand-write, so anything in it that rulesync did not write is not
9361
- * rulesync's to remove — dropping the last check targeting Cursor must not
9362
- * take somebody's hand-written review instructions with it. Deletion is
9363
- * therefore allowed only for a file that is nothing but generated sections:
9364
- * one that carries no marker at all, or that carries hand-written text ahead
9365
- * of the first marker, stays.
9366
- */
9367
- static async canDeleteAuxiliaryFiles({ outputRoot }) {
9368
- const paths = CursorCheck.getSettablePaths();
9369
- const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath ?? "BUGBOT.md"));
9370
- if (fileContent === null) return true;
9371
- return isOnlyGeneratedSections(fileContent);
9372
- }
9373
- static fromRulesyncCheck(_params) {
9374
- throw new Error("Cursor checks are built from all checks at once; use fromRulesyncChecks.");
9375
- }
9376
- static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
9377
- if (rulesyncChecks.length === 0) return [];
9378
- const paths = CursorCheck.getSettablePaths({ global });
9379
- const relativeFilePath = paths.relativeFilePath ?? "BUGBOT.md";
9380
- const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
9381
- 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.`);
9382
- const fileContent = renderCheckFile(rulesyncChecks);
9383
- return [new CursorCheck({
9384
- outputRoot,
9385
- relativeDirPath: paths.relativeDirPath,
9386
- relativeFilePath,
9387
- fileContent,
9388
- global
9389
- })];
9390
- }
9391
- static async fromFile({ outputRoot = process.cwd(), global = false }) {
9392
- const paths = CursorCheck.getSettablePaths({ global });
9393
- const relativeFilePath = paths.relativeFilePath ?? "BUGBOT.md";
9394
- const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
9395
- return new CursorCheck({
9396
- outputRoot,
9397
- relativeDirPath: paths.relativeDirPath,
9398
- relativeFilePath,
9399
- fileContent: await readFileContentOrNull(filePath) ?? "",
9400
- global
9401
- });
9402
- }
9403
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
9404
- return new CursorCheck({
9405
- outputRoot,
9406
- relativeDirPath,
9407
- relativeFilePath,
9408
- fileContent: "",
9409
- validate: false,
9410
- global
9411
- });
9412
- }
9413
- validate() {
10177
+ static getAggregatedCheckConfig() {
9414
10178
  return {
9415
- success: true,
9416
- error: null
10179
+ displayName: "Cursor",
10180
+ toolTarget: "cursor",
10181
+ fallbackCheckName: "bugbot",
10182
+ handWrittenPreamble: "replace"
9417
10183
  };
9418
10184
  }
9419
- toRulesyncCheck() {
9420
- const first = this.toRulesyncChecks()[0];
9421
- if (!first) throw new Error(`No check instructions found in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
9422
- return first;
9423
- }
9424
- toRulesyncChecks() {
9425
- return splitCheckFile({
9426
- fileContent: this.getFileContent(),
9427
- fallbackName: FALLBACK_CHECK_NAME$2
9428
- });
9429
- }
9430
10185
  };
9431
10186
  //#endregion
9432
10187
  //#region src/constants/factorydroid-paths.ts
@@ -9463,7 +10218,6 @@ const FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME = "review-guidelines";
9463
10218
  const FACTORYDROID_REVIEW_GUIDELINES_DIR_PATH = join(FACTORYDROID_SKILLS_DIR_PATH, FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME);
9464
10219
  //#endregion
9465
10220
  //#region src/features/checks/factorydroid-check.ts
9466
- const FALLBACK_CHECK_NAME$1 = FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME;
9467
10221
  /**
9468
10222
  * Drop the YAML frontmatter of a hand-authored `review-guidelines` skill before
9469
10223
  * the file is split into checks.
@@ -9498,6 +10252,9 @@ function stripSkillFrontmatter(fileContent) {
9498
10252
  content = rest.slice(closing.index + closing[0].length).replace(LEADING_BLANK_LINE_PATTERN, "");
9499
10253
  }
9500
10254
  }
10255
+ function handWrittenWarning({ filePath, displayName, toolTarget }) {
10256
+ 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.`;
10257
+ }
9501
10258
  /**
9502
10259
  * Checks adapter for Factory Droid's code-review guidelines
9503
10260
  * (`.factory/skills/review-guidelines/SKILL.md`).
@@ -9506,9 +10263,10 @@ function stripSkillFrontmatter(fileContent) {
9506
10263
  * "repository-specific review guidelines" from a skill named
9507
10264
  * `review-guidelines` and injects them into every review run. That makes the
9508
10265
  * output a single aggregated file like Cursor Bugbot's and Rovo Dev's, so every
9509
- * `.rulesync/checks/*.md` targeting Factory Droid collapses into it via
9510
- * {@link fromRulesyncChecks}, each check written as a marked section (see
9511
- * `aggregated-check-file.ts` for the marker convention the adapters share).
10266
+ * `.rulesync/checks/*.md` targeting Factory Droid collapses into it via the
10267
+ * `fromRulesyncChecks` on {@link AggregatedToolCheck}, each check written as a
10268
+ * marked section (see `aggregated-check-file.ts` for the marker convention the
10269
+ * three aggregated adapters share).
9512
10270
  *
9513
10271
  * The file is plain Markdown with no frontmatter, matching Factory's documented
9514
10272
  * example. Frontmatter would also be self-defeating here: `renderCheckFile`
@@ -9526,8 +10284,9 @@ function stripSkillFrontmatter(fileContent) {
9526
10284
  * The output lives inside the same `.factory/skills/` tree the `skills` feature
9527
10285
  * writes, so a user-authored `review-guidelines` skill collides with it. The
9528
10286
  * path has one owner rather than a merge rule, and the owner is this feature:
9529
- * {@link fromRulesyncChecks} leaves a file holding anything rulesync did not
9530
- * write untouched, and {@link canDeleteAuxiliaryFiles} refuses to remove one.
10287
+ * generating leaves a file holding anything rulesync did not write untouched —
10288
+ * the `skip` policy below — and the base's `canDeleteAuxiliaryFiles` refuses to
10289
+ * remove one.
9531
10290
  * Their content is somebody's own writing and rulesync cannot reconstruct it,
9532
10291
  * so neither direction guesses. That is stricter than Cursor Bugbot's
9533
10292
  * replace-and-warn, and deliberately: `.cursor/BUGBOT.md` is a path only the
@@ -9537,92 +10296,23 @@ function stripSkillFrontmatter(fileContent) {
9537
10296
  *
9538
10297
  * @see https://docs.factory.ai/software-factory/code-review-ci
9539
10298
  */
9540
- var FactorydroidCheck = class FactorydroidCheck extends ToolCheck {
10299
+ var FactorydroidCheck = class extends AggregatedToolCheck {
9541
10300
  static getSettablePaths(_options = {}) {
9542
10301
  return {
9543
10302
  relativeDirPath: FACTORYDROID_REVIEW_GUIDELINES_DIR_PATH,
9544
10303
  relativeFilePath: SKILL_FILE_NAME
9545
10304
  };
9546
10305
  }
9547
- static isTargetedByRulesyncCheck(rulesyncCheck) {
9548
- return this.isTargetedByRulesyncCheckDefault({
9549
- rulesyncCheck,
9550
- toolTarget: "factorydroid"
9551
- });
9552
- }
9553
- /**
9554
- * Ownership guard the processor consults before it deletes anything for this
9555
- * tool. `review-guidelines` is an ordinary skill directory a user may have
9556
- * authored by hand, so dropping the last check targeting Factory Droid must
9557
- * not take their review instructions with it. Deletion is allowed only for a
9558
- * file that is nothing but generated sections.
9559
- */
9560
- static async canDeleteAuxiliaryFiles({ outputRoot }) {
9561
- const paths = FactorydroidCheck.getSettablePaths();
9562
- const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath ?? "SKILL.md"));
9563
- if (fileContent === null) return true;
9564
- return isOnlyGeneratedSections(fileContent);
9565
- }
9566
- static fromRulesyncCheck(_params) {
9567
- throw new Error("Factory Droid checks are built from all checks at once; use fromRulesyncChecks.");
9568
- }
9569
- static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
9570
- if (rulesyncChecks.length === 0) return [];
9571
- const paths = FactorydroidCheck.getSettablePaths({ global });
9572
- const relativeFilePath = paths.relativeFilePath ?? "SKILL.md";
9573
- const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
9574
- if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) {
9575
- 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.`);
9576
- return [];
9577
- }
9578
- const fileContent = renderCheckFile(rulesyncChecks);
9579
- return [new FactorydroidCheck({
9580
- outputRoot,
9581
- relativeDirPath: paths.relativeDirPath,
9582
- relativeFilePath,
9583
- fileContent,
9584
- global
9585
- })];
9586
- }
9587
- static async fromFile({ outputRoot = process.cwd(), global = false }) {
9588
- const paths = FactorydroidCheck.getSettablePaths({ global });
9589
- const relativeFilePath = paths.relativeFilePath ?? "SKILL.md";
9590
- const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
9591
- return new FactorydroidCheck({
9592
- outputRoot,
9593
- relativeDirPath: paths.relativeDirPath,
9594
- relativeFilePath,
9595
- fileContent: await readFileContentOrNull(filePath) ?? "",
9596
- global
9597
- });
9598
- }
9599
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
9600
- return new FactorydroidCheck({
9601
- outputRoot,
9602
- relativeDirPath,
9603
- relativeFilePath,
9604
- fileContent: "",
9605
- validate: false,
9606
- global
9607
- });
9608
- }
9609
- validate() {
10306
+ static getAggregatedCheckConfig() {
9610
10307
  return {
9611
- success: true,
9612
- error: null
10308
+ displayName: "Factory Droid",
10309
+ toolTarget: "factorydroid",
10310
+ fallbackCheckName: FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME,
10311
+ handWrittenPreamble: "skip",
10312
+ handWrittenWarning,
10313
+ transformImportedContent: stripSkillFrontmatter
9613
10314
  };
9614
10315
  }
9615
- toRulesyncCheck() {
9616
- const first = this.toRulesyncChecks()[0];
9617
- if (!first) throw new Error(`No check instructions found in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
9618
- return first;
9619
- }
9620
- toRulesyncChecks() {
9621
- return splitCheckFile({
9622
- fileContent: stripSkillFrontmatter(this.getFileContent()),
9623
- fallbackName: FALLBACK_CHECK_NAME$1
9624
- });
9625
- }
9626
10316
  };
9627
10317
  //#endregion
9628
10318
  //#region src/constants/hermesagent-paths.ts
@@ -9881,7 +10571,6 @@ var HermesagentCheck = class HermesagentCheck extends ToolCheck {
9881
10571
  };
9882
10572
  //#endregion
9883
10573
  //#region src/features/checks/rovodev-check.ts
9884
- const FALLBACK_CHECK_NAME = "review-agent";
9885
10574
  /**
9886
10575
  * Checks adapter for Rovo Dev CLI's code-review custom instructions
9887
10576
  * (`.rovodev/.review-agent.md`).
@@ -9890,9 +10579,9 @@ const FALLBACK_CHECK_NAME = "review-agent";
9890
10579
  * `.rovodev/` folder — no frontmatter, and note the leading dot in the file
9891
10580
  * name. Like Cursor Bugbot it is a single aggregated file rather than a file
9892
10581
  * per check, so every `.rulesync/checks/*.md` targeting Rovo Dev collapses into
9893
- * it via {@link fromRulesyncChecks}, with each check written as a marked
9894
- * section (see `aggregated-check-file.ts` for the marker convention the two
9895
- * adapters share).
10582
+ * it via the `fromRulesyncChecks` on {@link AggregatedToolCheck}, with each
10583
+ * check written as a marked section (see `aggregated-check-file.ts` for the
10584
+ * marker convention the three aggregated adapters share).
9896
10585
  *
9897
10586
  * Rovo Dev reads the file as free prose, so a check's `severity` and `tools`
9898
10587
  * have no equivalent there: they are not written and do not come back on
@@ -9904,88 +10593,21 @@ const FALLBACK_CHECK_NAME = "review-agent";
9904
10593
  *
9905
10594
  * @see https://support.atlassian.com/rovo/docs/set-custom-instructions-for-code-reviews/
9906
10595
  */
9907
- var RovodevCheck = class RovodevCheck extends ToolCheck {
10596
+ var RovodevCheck = class extends AggregatedToolCheck {
9908
10597
  static getSettablePaths(_options = {}) {
9909
10598
  return {
9910
10599
  relativeDirPath: ROVODEV_DIR,
9911
10600
  relativeFilePath: ROVODEV_REVIEW_AGENT_FILE_NAME
9912
10601
  };
9913
10602
  }
9914
- static isTargetedByRulesyncCheck(rulesyncCheck) {
9915
- return this.isTargetedByRulesyncCheckDefault({
9916
- rulesyncCheck,
9917
- toolTarget: "rovodev"
9918
- });
9919
- }
9920
- /**
9921
- * Ownership guard the processor consults before it deletes anything for this
9922
- * tool. `.review-agent.md` is a file Rovo Dev's own documentation tells users
9923
- * to hand-write, so anything in it that rulesync did not write is not
9924
- * rulesync's to remove — dropping the last check targeting Rovo Dev must not
9925
- * take somebody's hand-written review instructions with it.
9926
- */
9927
- static async canDeleteAuxiliaryFiles({ outputRoot }) {
9928
- const paths = RovodevCheck.getSettablePaths();
9929
- const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath ?? ".review-agent.md"));
9930
- if (fileContent === null) return true;
9931
- return isOnlyGeneratedSections(fileContent);
9932
- }
9933
- static fromRulesyncCheck(_params) {
9934
- throw new Error("Rovo Dev checks are built from all checks at once; use fromRulesyncChecks.");
9935
- }
9936
- static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
9937
- if (rulesyncChecks.length === 0) return [];
9938
- const paths = RovodevCheck.getSettablePaths({ global });
9939
- const relativeFilePath = paths.relativeFilePath ?? ".review-agent.md";
9940
- const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
9941
- 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.`);
9942
- return [new RovodevCheck({
9943
- outputRoot,
9944
- relativeDirPath: paths.relativeDirPath,
9945
- relativeFilePath,
9946
- fileContent: renderCheckFile(rulesyncChecks),
9947
- global
9948
- })];
9949
- }
9950
- static async fromFile({ outputRoot = process.cwd(), global = false }) {
9951
- const paths = RovodevCheck.getSettablePaths({ global });
9952
- const relativeFilePath = paths.relativeFilePath ?? ".review-agent.md";
9953
- const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
9954
- return new RovodevCheck({
9955
- outputRoot,
9956
- relativeDirPath: paths.relativeDirPath,
9957
- relativeFilePath,
9958
- fileContent: await readFileContentOrNull(filePath) ?? "",
9959
- global
9960
- });
9961
- }
9962
- static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
9963
- return new RovodevCheck({
9964
- outputRoot,
9965
- relativeDirPath,
9966
- relativeFilePath,
9967
- fileContent: "",
9968
- validate: false,
9969
- global
9970
- });
9971
- }
9972
- validate() {
10603
+ static getAggregatedCheckConfig() {
9973
10604
  return {
9974
- success: true,
9975
- error: null
10605
+ displayName: "Rovo Dev",
10606
+ toolTarget: "rovodev",
10607
+ fallbackCheckName: "review-agent",
10608
+ handWrittenPreamble: "replace"
9976
10609
  };
9977
10610
  }
9978
- toRulesyncCheck() {
9979
- const first = this.toRulesyncChecks()[0];
9980
- if (!first) throw new Error(`No check instructions found in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
9981
- return first;
9982
- }
9983
- toRulesyncChecks() {
9984
- return splitCheckFile({
9985
- fileContent: this.getFileContent(),
9986
- fallbackName: FALLBACK_CHECK_NAME
9987
- });
9988
- }
9989
10611
  };
9990
10612
  //#endregion
9991
10613
  //#region src/constants/codexcli-paths.ts
@@ -11193,7 +11815,7 @@ var ChecksProcessor = class extends FeatureProcessor {
11193
11815
  this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
11194
11816
  return [];
11195
11817
  }
11196
- const mdFiles = (await listDirectoryFiles(checksDir)).filter((file) => file.endsWith(".md"));
11818
+ const mdFiles = (await listDirectoryEntryNames(checksDir)).filter((file) => file.endsWith(".md"));
11197
11819
  if (mdFiles.length === 0) {
11198
11820
  this.logger.debug(`No markdown files found in rulesync checks directory: ${checksDir}`);
11199
11821
  return [];
@@ -16015,6 +16637,7 @@ const ZCODE_SKILLS_DIR_PATH = join(ZCODE_DIR, "skills");
16015
16637
  const ZCODE_CONFIG_FILE_NAME = "config.json";
16016
16638
  const ZCODE_GLOBAL_CONFIG_DIR_PATH = join(ZCODE_DIR, "cli");
16017
16639
  const ZCODE_MCP_SERVERS_KEY = "servers";
16640
+ const ZCODE_AGENTS_DIR_PATH = join(ZCODE_DIR, "agents");
16018
16641
  //#endregion
16019
16642
  //#region src/features/commands/zcode-command.ts
16020
16643
  /**
@@ -16977,6 +17600,52 @@ function compact(obj) {
16977
17600
  return result;
16978
17601
  }
16979
17602
  //#endregion
17603
+ //#region src/utils/quote-value.ts
17604
+ /**
17605
+ * How much of a value read off disk a diagnostic quotes.
17606
+ *
17607
+ * Enough to recognize which entry is meant, and no more. A warning names the
17608
+ * offending value so the reader can find it, but the values these warnings
17609
+ * quote come from files rulesync did not write — a tool's own settings, a
17610
+ * machine-local overrides file, a repository fetched from elsewhere — and they
17611
+ * no longer stop at a terminal: they travel into a `--json` document another
17612
+ * program parses and into an MCP result an agent reads as context. A command
17613
+ * line or a header is the shape most likely to carry a credential, and a long
17614
+ * value is the shape most likely to carry instructions aimed at the agent.
17615
+ */
17616
+ const MAX_QUOTED_VALUE_LENGTH = 60;
17617
+ /**
17618
+ * A short, quotable rendering of a value for a diagnostic.
17619
+ *
17620
+ * Serialized rather than interpolated, because an unquoted value is what lets a
17621
+ * crafted one read as a second line; stripped of the control characters
17622
+ * `JSON.stringify` leaves intact (it escapes C0 only, not the C1 range or the
17623
+ * bidirectional overrides); and truncated.
17624
+ */
17625
+ function quoteValueForWarning(value) {
17626
+ return truncateText({
17627
+ text: stripControlCharacters(serialize(value)),
17628
+ maxLength: MAX_QUOTED_VALUE_LENGTH,
17629
+ suffix: "…(truncated)"
17630
+ });
17631
+ }
17632
+ function serialize(value) {
17633
+ try {
17634
+ return JSON.stringify(value, stripStrings) ?? String(value);
17635
+ } catch {
17636
+ return `[unserializable ${typeof value}]`;
17637
+ }
17638
+ }
17639
+ /**
17640
+ * Strip the control characters out of every string before `JSON.stringify`
17641
+ * sees it, not only out of the document it produces: `JSON.stringify` escapes
17642
+ * a C0 character into the six literal characters `\u001b`, which no later pass
17643
+ * over the output can recognize as a control character again.
17644
+ */
17645
+ function stripStrings(_key, value) {
17646
+ return typeof value === "string" ? stripControlCharacters(value) : value;
17647
+ }
17648
+ //#endregion
16980
17649
  //#region src/features/hooks/tool-hooks-converter.ts
16981
17650
  function isToolMatcherEntry(x) {
16982
17651
  if (x === null || typeof x !== "object") return false;
@@ -17078,7 +17747,7 @@ function emitPassthroughFields({ def, hookType, eventName, fields, isValid, warn
17078
17747
  if (!isValid({
17079
17748
  value,
17080
17749
  canonical
17081
- })) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${JSON.stringify(value)} is not a value this tool can express as "${tool}".`);
17750
+ })) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${quoteValueForWarning(value)} is not a value this tool can express as "${tool}".`);
17082
17751
  }
17083
17752
  return Object.fromEntries(fields.filter(({ canonical, commandOnly }) => isFieldApplicable({
17084
17753
  commandOnly,
@@ -17173,7 +17842,7 @@ function describeScalarConstraint({ canonical, value }) {
17173
17842
  if (issue === void 0) return `it is not a value the canonical "${canonical}" field accepts.`;
17174
17843
  return `it does not satisfy the canonical "${canonical}" field: ${issue.message}.`;
17175
17844
  }
17176
- const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${JSON.stringify(value)}) while importing a hook: ${describeScalarConstraint({
17845
+ const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${quoteValueForWarning(value)}) while importing a hook: ${describeScalarConstraint({
17177
17846
  canonical,
17178
17847
  value
17179
17848
  })} Importing it would fail validation on the next run.`;
@@ -17200,7 +17869,7 @@ function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }
17200
17869
  if (first === void 0) continue;
17201
17870
  const firstStable = stableJson(first);
17202
17871
  const agrees = (value) => isGroupPassthroughValue(value, valueType) && stableJson(value) === firstStable;
17203
- 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.`);
17872
+ 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.`);
17204
17873
  emitted[tool] = first;
17205
17874
  }
17206
17875
  return emitted;
@@ -17687,7 +18356,7 @@ function describeGroupSkipReason({ rawEntry, converterConfig }) {
17687
18356
  for (const { tool, valueType, subdividesGroup } of converterConfig.groupPassthroughFields ?? []) {
17688
18357
  const value = entry[tool];
17689
18358
  if (subdividesGroup !== true || value === void 0) continue;
17690
- 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.`;
18359
+ 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.`;
17691
18360
  }
17692
18361
  }
17693
18362
  /**
@@ -17704,7 +18373,7 @@ function describeHookSkipReason({ h, rawEntry, hookType, converterConfig }) {
17704
18373
  value,
17705
18374
  canonical: field
17706
18375
  })) continue;
17707
- return `Skipping a hook while importing: its "${field}" (${JSON.stringify(value)}) is unusable — ${describeScalarConstraint({
18376
+ return `Skipping a hook while importing: its "${field}" (${quoteValueForWarning(value)}) is unusable — ${describeScalarConstraint({
17708
18377
  canonical: field,
17709
18378
  value
17710
18379
  })} Keeping the hook without it would change what it does, so the whole hook is skipped.`;
@@ -18026,7 +18695,22 @@ async function readSettingsWithLocalOverlay({ outputRoot, relativeDirPath, baseF
18026
18695
  }
18027
18696
  /** Quotes a name read off disk, the way every other such name is logged. */
18028
18697
  function quoteKey(key) {
18029
- return JSON.stringify(stripControlCharacters(key));
18698
+ return quoteValueForWarning(key);
18699
+ }
18700
+ /**
18701
+ * How many keys the warning names before it stops counting.
18702
+ *
18703
+ * The keys come from a file rulesync did not write, and the warning now travels
18704
+ * into `--json` documents and MCP results as well as onto a console. A settings
18705
+ * file with hundreds of top-level keys is unusual but not impossible, and the
18706
+ * point of the sentence is to make the reader open the file — naming the first
18707
+ * few does that as well as naming all of them.
18708
+ */
18709
+ const MAX_LISTED_KEYS = 20;
18710
+ function listKeys(keys) {
18711
+ const named = keys.slice(0, MAX_LISTED_KEYS).map(quoteKey).join(", ");
18712
+ const rest = keys.length - MAX_LISTED_KEYS;
18713
+ return rest > 0 ? `${named} and ${rest} more` : named;
18030
18714
  }
18031
18715
  /**
18032
18716
  * Name the settings the machine-local file contributed, so nobody publishes one
@@ -18046,8 +18730,8 @@ function warnAboutLocalKeys({ localParsed, configPath, toolLabel, sensitiveKeys,
18046
18730
  const keys = Object.keys(localParsed);
18047
18731
  if (keys.length === 0) return;
18048
18732
  const flagged = keys.filter((key) => sensitiveKeys.includes(key));
18049
- 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.`;
18050
- 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}`);
18733
+ 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.`;
18734
+ 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}`);
18051
18735
  }
18052
18736
  //#endregion
18053
18737
  //#region src/utils/augmentcode-settings.ts
@@ -22742,6 +23426,9 @@ function withToolTargetPrefix({ logger, toolTarget }) {
22742
23426
  get silent() {
22743
23427
  return logger.silent;
22744
23428
  },
23429
+ get reportsWhileSilent() {
23430
+ return logger.reportsWhileSilent;
23431
+ },
22745
23432
  get jsonMode() {
22746
23433
  return logger.jsonMode;
22747
23434
  },
@@ -28856,7 +29543,7 @@ function convertFromMusecodeFormat(musecodeMcp) {
28856
29543
  if (key === "mode") {
28857
29544
  const mode = asMusecodeMode(value);
28858
29545
  if (mode === void 0) {
28859
- 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.`);
29546
+ 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.`);
28860
29547
  continue;
28861
29548
  }
28862
29549
  converted.musecodeMode = mode;
@@ -30060,7 +30747,7 @@ function pointerLabels(global) {
30060
30747
  async function warnAtDocumentedDefault({ existing, outputRoot, logger }) {
30061
30748
  const { pointer, configLabel, mcpLabel } = pointerLabels(true);
30062
30749
  const displaced = await describeDisplacedGlobalServers({ outputRoot });
30063
- 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}".`));
30750
+ 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}".`));
30064
30751
  }
30065
30752
  /**
30066
30753
  * Announce a pointer that was just written. Warned rather than noted in global
@@ -30117,10 +30804,10 @@ async function applyMcpConfigPointer({ existingMcp, global, hasLiveServers, outp
30117
30804
  return false;
30118
30805
  }
30119
30806
  if (global && normalizedExisting !== void 0 && envVarMcpFileSpellings({ fileName: "mcp.json" }).includes(normalizedExisting)) {
30120
- 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.`);
30807
+ 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.`);
30121
30808
  return false;
30122
30809
  }
30123
- 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}".`);
30810
+ 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}".`);
30124
30811
  return false;
30125
30812
  }
30126
30813
  /**
@@ -31740,11 +32427,10 @@ var ToolPermissions = class extends ToolFile {
31740
32427
  throw new Error("Please implement this method in the subclass.");
31741
32428
  }
31742
32429
  toRulesyncPermissionsDefault({ fileContent }) {
31743
- return new RulesyncPermissions({
32430
+ return RulesyncPermissions.fromImportedFileContent({
31744
32431
  outputRoot: this.outputRoot,
31745
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
31746
- relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
31747
- fileContent: withoutBlankPermissionPatterns({ fileContent })
32432
+ fileContent,
32433
+ sourcePath: this.getRelativePathFromCwd()
31748
32434
  });
31749
32435
  }
31750
32436
  static async fromFile(_params) {
@@ -35502,7 +36188,7 @@ function asCursorPermissionEntryArray(value, logger, fieldLabel) {
35502
36188
  if (!Array.isArray(value)) return [];
35503
36189
  const result = [];
35504
36190
  for (const item of value) if (typeof item === "string") result.push(item);
35505
- else logger?.warn(`Cursor CLI permissions${fieldLabel ? `.${fieldLabel}` : ""} contains a non-string entry; dropping ${JSON.stringify(item)}.`);
36191
+ else logger?.warn(`Cursor CLI permissions${fieldLabel ? `.${fieldLabel}` : ""} contains a non-string entry; dropping ${quoteValueForWarning(item)}.`);
35506
36192
  return result;
35507
36193
  }
35508
36194
  /**
@@ -37356,7 +38042,10 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
37356
38042
  fileContent: this.getFileContent()
37357
38043
  });
37358
38044
  const rawProvenance = (isRecord$1(config.permissions) ? config.permissions : {}).rulesync;
37359
- const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(isRecord$1(rawProvenance) ? withoutBlankPermissionPatternsIn({ config: rawProvenance }) : rawProvenance);
38045
+ const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(isRecord$1(rawProvenance) ? withoutBlankPermissionKeysIn({
38046
+ config: rawProvenance,
38047
+ sourcePath: this.getRelativePathFromCwd()
38048
+ }) : rawProvenance);
37360
38049
  const provenance = parsedProvenance.success ? parsedProvenance.data : { permission: {} };
37361
38050
  const permission = clonePermissionBlock(provenance.permission);
37362
38051
  reconcileCommandAllowlist({
@@ -37384,14 +38073,13 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
37384
38073
  permission,
37385
38074
  ...Object.keys(hermes).length > 0 && { hermes }
37386
38075
  };
37387
- return new RulesyncPermissions({
38076
+ return RulesyncPermissions.fromImportedFileContent({
37388
38077
  outputRoot: getHermesagentRulesyncOutputRoot({
37389
38078
  nativeOutputRoot: this.outputRoot,
37390
38079
  global: this.global
37391
38080
  }),
37392
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
37393
- relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
37394
- fileContent: withoutBlankPermissionPatterns({ fileContent: JSON.stringify(imported, null, 2) })
38081
+ sourcePath: this.getRelativePathFromCwd(),
38082
+ fileContent: JSON.stringify(imported, null, 2)
37395
38083
  });
37396
38084
  }
37397
38085
  static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false }) {
@@ -38292,17 +38980,16 @@ var KimiCodePermissions = class KimiCodePermissions extends ToolPermissions {
38292
38980
  ...nativeRules.length > 0 && { rules: nativeRules },
38293
38981
  ...tools && { tools }
38294
38982
  };
38295
- return new RulesyncPermissions({
38983
+ return RulesyncPermissions.fromImportedFileContent({
38296
38984
  outputRoot: getKimiCodeRulesyncOutputRoot({
38297
38985
  nativeOutputRoot: this.outputRoot,
38298
38986
  global: this.global
38299
38987
  }),
38300
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
38301
- relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
38302
- fileContent: withoutBlankPermissionPatterns({ fileContent: JSON.stringify({
38988
+ sourcePath: this.getRelativePathFromCwd(),
38989
+ fileContent: JSON.stringify({
38303
38990
  permission,
38304
38991
  ...Object.keys(toolOverride).length > 0 && { "kimi-code": toolOverride }
38305
- }, null, 2) })
38992
+ }, null, 2)
38306
38993
  });
38307
38994
  }
38308
38995
  static forDeletion({ outputRoot = process.cwd() }) {
@@ -40876,6 +41563,28 @@ function pickSecurityPolicies(source, report) {
40876
41563
  * Ambiguous-width characters are counted as one column, which is what a
40877
41564
  * terminal running a Latin font does.
40878
41565
  *
41566
+ * Two of the ranges are here for a narrower reason: a name the skill prompt can
41567
+ * offer may not be counted narrower here than the prompt's own renderer counts
41568
+ * it, or a label that fits the budget wraps anyway and paints the second row
41569
+ * the budget exists to prevent. `@inquirer/core` measures with
41570
+ * `fast-string-width`, which takes the whole of `Script=Hangul` as wide and
41571
+ * every `Emoji_Modifier_Base` as an emoji. So the Hangul jamo are taken to
41572
+ * U+11FF rather than stopping at the leading consonants, and the modifier bases
41573
+ * are named beside the emoji: U+261D, U+26F9 and the two hands of U+270C–U+270D
41574
+ * are `Emoji` without being `Emoji_Presentation`, and were the only characters
41575
+ * outside Hangul this counted at one column while the renderer counted two.
41576
+ *
41577
+ * The rule is over the names that can reach the prompt, which is a smaller set
41578
+ * than the characters that exist. The renderer counts a tab at eight columns and
41579
+ * the Hangul fillers at two, where this counts one and none: a name carrying
41580
+ * either is refused outright by `hasDeceptiveHiddenCharacters` — the tab as a
41581
+ * control character, the fillers as characters that draw as nothing — and never
41582
+ * becomes a row to be measured. The two joiners are the invisible characters
41583
+ * that check lets through, so they are counted below rather than left to the
41584
+ * zero-width rule. Where this is used to lay out text of the tool's own rather
41585
+ * than to bound an untrusted name, the difference is a column of alignment and
41586
+ * not a forged row.
41587
+ *
40879
41588
  * The wide planes are taken whole rather than range by range — Tangut, Khitan
40880
41589
  * and Nushu together are U+17000–U+18DFF, and the kana supplements are
40881
41590
  * U+1AFF0–U+1B2FF — because a gap between two of them is exactly the character
@@ -40888,7 +41597,7 @@ function pickSecurityPolicies(source, report) {
40888
41597
  * is canonically the ordinary ideograph U+8C48, and a range that starts there
40889
41598
  * instead silently swallows thirty thousand code points that are not wide.
40890
41599
  */
40891
- 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;
41600
+ 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;
40892
41601
  /**
40893
41602
  * U+FE0F VARIATION SELECTOR-16, which takes no width of its own but asks the
40894
41603
  * character before it to be drawn as an emoji — that is, in two columns rather
@@ -40902,6 +41611,27 @@ const COMBINING_MARK_PATTERN = /[\p{Mn}\p{Me}]/u;
40902
41611
  /** The characters that take no width at all, marks aside. */
40903
41612
  const ZERO_WIDTH_CHARACTERS_PATTERN = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
40904
41613
  /**
41614
+ * The zero-width joiner and its non-joining twin, which the renderer spends a
41615
+ * column on apiece.
41616
+ *
41617
+ * A terminal draws neither, and every other character that draws as nothing is
41618
+ * counted at nothing here. These two are the exception because they are the
41619
+ * only invisible characters `hasDeceptiveHiddenCharacters` lets through — a
41620
+ * Persian or Indic name spells a word with one, and an emoji is a chain of them
41621
+ * — so they are the only ones an attacker can put in a name that reaches the
41622
+ * prompt. `fast-string-width`, which is where the prompt's own wrapping is
41623
+ * decided, counts each of them as a column, and 40 of them in a name is 40
41624
+ * columns of budget this would otherwise hand over for free: enough for a name
41625
+ * measured at 39 columns to be drawn at 77 and wrap a forged row underneath
41626
+ * itself.
41627
+ *
41628
+ * The cost is that an emoji built from a chain is overstated by a column per
41629
+ * joiner, on top of the two columns per component it is already overstated by.
41630
+ * Overstating shortens a label that did not need it; understating lets one
41631
+ * wrap.
41632
+ */
41633
+ const RENDERER_COUNTED_JOINERS = /\u200c|\u200d/u;
41634
+ /**
40905
41635
  * How many marks a single character is allowed to carry for free.
40906
41636
  *
40907
41637
  * A written language stacks two or three at most — a Devanagari vowel sign and
@@ -40929,6 +41659,7 @@ function widthInContext(params) {
40929
41659
  const { character, precedingMarks } = params;
40930
41660
  if (character === EMOJI_PRESENTATION_SELECTOR) return 1;
40931
41661
  if (isCombiningMark(character)) return precedingMarks < FREE_MARKS_PER_CHARACTER ? 0 : 1;
41662
+ if (RENDERER_COUNTED_JOINERS.test(character)) return 1;
40932
41663
  if (ZERO_WIDTH_CHARACTERS_PATTERN.test(character)) return 0;
40933
41664
  return WIDE_CHARACTERS_PATTERN.test(character) ? 2 : 1;
40934
41665
  }
@@ -40951,6 +41682,8 @@ function displayWidthOf(text) {
40951
41682
  }
40952
41683
  return width;
40953
41684
  }
41685
+ /** The mark a cut string ends in. */
41686
+ const SHORTENING_ELLIPSIS = "…";
40954
41687
  /**
40955
41688
  * Cut `text` down to at most `budget` columns, marking the cut with an ellipsis.
40956
41689
  *
@@ -40976,7 +41709,7 @@ function shortenToWidth(params) {
40976
41709
  width += characterWidth;
40977
41710
  marks = isCombiningMark(character) ? marks + 1 : 0;
40978
41711
  }
40979
- return `${kept.join("")}…`;
41712
+ return `${kept.join("")}${SHORTENING_ELLIPSIS}`;
40980
41713
  }
40981
41714
  //#endregion
40982
41715
  //#region src/features/permissions/vibe-permissions.ts
@@ -44764,6 +45497,83 @@ const NESTED_SCAN_EXCLUDED_ROOT_DIRS = [
44764
45497
  ];
44765
45498
  //#endregion
44766
45499
  //#region src/features/skills/claudecode-skill.ts
45500
+ /**
45501
+ * The `.claude/skills` tail every scanned root is expected to end with, written
45502
+ * posix-separated so it can be compared against a resolved relative path, and
45503
+ * split into its segments so what sits above it can be taken apart.
45504
+ *
45505
+ * Split here rather than through the shared helper: this is evaluated as the
45506
+ * module loads, before a test that mocks the file utilities can supply one.
45507
+ */
45508
+ const CLAUDECODE_SKILLS_DIR_SEGMENTS = CLAUDECODE_SKILLS_DIR_PATH.split(sep);
45509
+ const CLAUDECODE_SKILLS_DIR_POSIX_PATH = CLAUDECODE_SKILLS_DIR_SEGMENTS.join("/");
45510
+ /**
45511
+ * The segment that puts `relativeDirPath` inside a tree the nested scan
45512
+ * excludes, or `undefined` when none does. These are the same three rules the
45513
+ * scan states as glob `ignore` patterns below -- dependency trees at any depth,
45514
+ * build and vendoring directories at the project root, hidden directories other
45515
+ * than the `.claude` being matched -- and the two have to be kept in step.
45516
+ *
45517
+ * Saying them twice is what a second pass costs. globby matches its patterns
45518
+ * against the path it reports, before the `..` a rewritten directory name
45519
+ * carries is folded away and before a link in the path is resolved. A root
45520
+ * reported at `x/../node_modules/.claude/skills` matches none of the patterns
45521
+ * and then leads to the dependency tree they name, so the decision has to be
45522
+ * taken again on the path that is really read.
45523
+ *
45524
+ * The segments passed are the ones above the `.claude/skills` tail, taken from
45525
+ * the resolved path: a directory name may hold a backslash -- the whole reason a
45526
+ * path can arrive here misspelled -- so the split that produced them has to be on
45527
+ * `/`, the one separator no name can contain. The tail itself is the part the
45528
+ * glob matched and is not judged; `.claude` is hidden by definition and every
45529
+ * root the scan reports ends with it.
45530
+ */
45531
+ function excludedNestedScanSegment(segments) {
45532
+ return segments.find((segment, index) => NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.includes(segment) || index === 0 && NESTED_SCAN_EXCLUDED_ROOT_DIRS.includes(segment) || isHiddenPathSegment(segment));
45533
+ }
45534
+ /**
45535
+ * Whether a nested skills directory the scan reported can be used as an import
45536
+ * root: either the reason it cannot, or the path it resolves to relative to the
45537
+ * project, which the caller uses to tell two spellings of one root apart.
45538
+ *
45539
+ * A recursive glob cannot be swapped for a walk the way a flat one can, so the
45540
+ * path it hands back is checked instead. globby reads a backslash as a path
45541
+ * separator and rewrites it, so a root below a directory really named
45542
+ * `back\\slash` is reported at `back/slash`. Where that leads decides what to
45543
+ * do with it, and the spelling alone does not say: `back/slash` usually answers
45544
+ * to nothing, but `x\\..\\..\\outside` is reported at `x/../../outside`, which
45545
+ * climbs out of the project through the real sibling `x/`, and `a\\b` at `a/b`,
45546
+ * which may be a symbolic link out of the project that the scan — it passes
45547
+ * `followSymbolicLinks: false` — never meant to reach. Both are refused by
45548
+ * resolving the path rather than reading it.
45549
+ *
45550
+ * What is deliberately not refused is a rewritten path that stays inside the
45551
+ * project, such as `x\\..\\y` reported at `x/../y`. It names a real directory
45552
+ * `y`, and the scan reports that directory under this spelling *instead of* its
45553
+ * own, so refusing it would lose `y`'s skills rather than protect anything. The
45554
+ * skills under the directory that was really named are unreachable either way:
45555
+ * no path the scan can report leads back to a name holding a backslash.
45556
+ *
45557
+ * That last shape is the one case the scan cannot warn about. `a\\b` reported
45558
+ * at `a/b`, where `a/b` is itself a real directory, is indistinguishable from
45559
+ * the ordinary root `a/b` -- both are spelled the same and both are there -- so
45560
+ * the skills under `a\\b` are dropped without a word. Nothing in the path says
45561
+ * a second directory was ever involved.
45562
+ */
45563
+ async function checkNestedSkillsRoot({ outputRoot, dirPath }) {
45564
+ 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." };
45565
+ const realRelativeDirPath = await resolvedRelativePath({
45566
+ rootPath: outputRoot,
45567
+ targetPath: dirPath
45568
+ });
45569
+ if (posixRelativePathEscapesRoot(realRelativeDirPath)) return { reason: "it resolves outside the project." };
45570
+ const segments = realRelativeDirPath.split("/");
45571
+ const aboveTailSegments = segments.slice(0, -CLAUDECODE_SKILLS_DIR_SEGMENTS.length);
45572
+ 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.` };
45573
+ const excludedSegment = excludedNestedScanSegment(aboveTailSegments);
45574
+ if (excludedSegment !== void 0) return { reason: `it resolves inside ${JSON.stringify(stripControlCharacters(excludedSegment))}, which the nested scan excludes.` };
45575
+ return { realRelativeDirPath };
45576
+ }
44767
45577
  const ClaudecodeSkillFrontmatterSchema = z.looseObject({
44768
45578
  name: z.string(),
44769
45579
  description: z.string(),
@@ -45012,10 +45822,10 @@ var ClaudecodeSkill = class extends ToolSkill {
45012
45822
  *
45013
45823
  * @see https://code.claude.com/docs/en/skills
45014
45824
  */
45015
- static async getConfiguredImportRoots({ outputRoot, global = false }) {
45825
+ static async getConfiguredImportRoots({ outputRoot, global = false, logger }) {
45016
45826
  if (global) return [];
45017
45827
  const root = toPosixPath(outputRoot);
45018
- return filterOutPathsInGitIgnoredDirectories({
45828
+ const filteredDirPaths = filterOutPathsInGitIgnoredDirectories({
45019
45829
  rootDir: outputRoot,
45020
45830
  filePaths: await findFilesByGlobs([`${root}/*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`], {
45021
45831
  type: "dir",
@@ -45026,10 +45836,27 @@ var ClaudecodeSkill = class extends ToolSkill {
45026
45836
  ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
45027
45837
  ]
45028
45838
  })
45029
- }).toSorted().map((dirPath) => ({
45030
- outputRoot,
45031
- relativeDirPath: relative(outputRoot, dirPath)
45032
- }));
45839
+ }).toSorted();
45840
+ const roots = [];
45841
+ const seenRealRelativeDirPaths = /* @__PURE__ */ new Set([CLAUDECODE_SKILLS_DIR_POSIX_PATH]);
45842
+ for (const dirPath of filteredDirPaths) {
45843
+ const scannedDirPath = resolve(dirPath);
45844
+ const check = await checkNestedSkillsRoot({
45845
+ outputRoot,
45846
+ dirPath: scannedDirPath
45847
+ });
45848
+ if ("reason" in check) {
45849
+ logger?.warn(`Skipping the nested Claude Code skills directory ${JSON.stringify(stripControlCharacters(scannedDirPath))}: ${check.reason} Its skills are not imported.`);
45850
+ continue;
45851
+ }
45852
+ if (seenRealRelativeDirPaths.has(check.realRelativeDirPath)) continue;
45853
+ seenRealRelativeDirPaths.add(check.realRelativeDirPath);
45854
+ roots.push({
45855
+ outputRoot,
45856
+ relativeDirPath: relative(outputRoot, scannedDirPath)
45857
+ });
45858
+ }
45859
+ return roots;
45033
45860
  }
45034
45861
  getFrontmatter() {
45035
45862
  return ClaudecodeSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
@@ -49916,7 +50743,8 @@ var SkillsProcessor = class extends DirFeatureProcessor {
49916
50743
  const paths = factory.class.getSettablePaths({ global: this.global });
49917
50744
  const configuredRoots = factory.class.getConfiguredImportRoots ? await factory.class.getConfiguredImportRoots({
49918
50745
  outputRoot: this.outputRoot,
49919
- global: this.global
50746
+ global: this.global,
50747
+ logger: this.logger
49920
50748
  }) : [];
49921
50749
  const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
49922
50750
  const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
@@ -54632,6 +55460,152 @@ var VibeSubagent = class VibeSubagent extends ToolSubagent {
54632
55460
  }
54633
55461
  };
54634
55462
  //#endregion
55463
+ //#region src/features/subagents/zcode-subagent.ts
55464
+ const ZcodeSubagentFrontmatterSchema = z.looseObject({
55465
+ name: z.string(),
55466
+ description: z.optional(z.string()),
55467
+ model: z.optional(z.string()),
55468
+ thoughtLevel: z.optional(z.string()),
55469
+ color: z.optional(z.string()),
55470
+ tools: z.optional(z.array(z.string())),
55471
+ disallowedTools: z.optional(z.array(z.string())),
55472
+ maxTurns: z.optional(z.number().check(z.int(), z.positive())),
55473
+ injectAgentsMd: z.optional(z.boolean()),
55474
+ mcpServers: z.optional(z.array(z.string()))
55475
+ });
55476
+ /**
55477
+ * ZCode subagents.
55478
+ *
55479
+ * Each subagent is one Markdown file with YAML frontmatter, named after the
55480
+ * agent, under `~/.zcode/agents/`.
55481
+ *
55482
+ * Global scope only. The current Beta "manages global / user-level subagents
55483
+ * stored under `~/.zcode/agents/`", and creating or editing workspace /
55484
+ * project-level subagents "is not available yet" — so this adapter is
55485
+ * registered with `supportsProject: false` and never writes into a project's
55486
+ * own `.zcode/`. The relative path is nonetheless spelled against
55487
+ * {@link ZCODE_AGENTS_DIR_PATH} so the workspace scope needs nothing more than
55488
+ * flipping that flag if ZCode ships it.
55489
+ *
55490
+ * @see https://zcode.z.ai/en/docs/subagents
55491
+ */
55492
+ var ZcodeSubagent = class ZcodeSubagent extends ToolSubagent {
55493
+ frontmatter;
55494
+ body;
55495
+ constructor({ frontmatter, body, fileContent, ...rest }) {
55496
+ if (rest.validate !== false) {
55497
+ const result = ZcodeSubagentFrontmatterSchema.safeParse(frontmatter);
55498
+ if (!result.success) throw new Error(`Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
55499
+ }
55500
+ super({
55501
+ ...rest,
55502
+ fileContent: fileContent ?? stringifyFrontmatter(body, frontmatter)
55503
+ });
55504
+ this.frontmatter = frontmatter;
55505
+ this.body = body;
55506
+ }
55507
+ static getSettablePaths(_options = {}) {
55508
+ return { relativeDirPath: ZCODE_AGENTS_DIR_PATH };
55509
+ }
55510
+ getFrontmatter() {
55511
+ return this.frontmatter;
55512
+ }
55513
+ getBody() {
55514
+ return this.body;
55515
+ }
55516
+ toRulesyncSubagent() {
55517
+ const { name, description, ...rest } = this.frontmatter;
55518
+ return new RulesyncSubagent({
55519
+ outputRoot: ".",
55520
+ frontmatter: {
55521
+ targets: ["*"],
55522
+ name,
55523
+ description,
55524
+ ...Object.keys(rest).length > 0 && { zcode: rest }
55525
+ },
55526
+ body: this.body,
55527
+ relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
55528
+ relativeFilePath: this.getRelativeFilePath(),
55529
+ validate: true
55530
+ });
55531
+ }
55532
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
55533
+ const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
55534
+ const zcodeSection = rulesyncFrontmatter.zcode ?? {};
55535
+ const zcodeFrontmatter = {
55536
+ name: rulesyncFrontmatter.name,
55537
+ description: rulesyncFrontmatter.description,
55538
+ ...zcodeSection
55539
+ };
55540
+ const body = rulesyncSubagent.getBody();
55541
+ const fileContent = stringifyFrontmatter(body, zcodeFrontmatter, { avoidBlockScalars: true });
55542
+ const paths = this.getSettablePaths({ global });
55543
+ return new ZcodeSubagent({
55544
+ outputRoot,
55545
+ frontmatter: zcodeFrontmatter,
55546
+ body,
55547
+ relativeDirPath: paths.relativeDirPath,
55548
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
55549
+ fileContent,
55550
+ validate,
55551
+ global
55552
+ });
55553
+ }
55554
+ validate() {
55555
+ if (!this.frontmatter) return {
55556
+ success: true,
55557
+ error: null
55558
+ };
55559
+ const result = ZcodeSubagentFrontmatterSchema.safeParse(this.frontmatter);
55560
+ if (result.success) return {
55561
+ success: true,
55562
+ error: null
55563
+ };
55564
+ else return {
55565
+ success: false,
55566
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
55567
+ };
55568
+ }
55569
+ static isTargetedByRulesyncSubagent(rulesyncSubagent) {
55570
+ return this.isTargetedByRulesyncSubagentDefault({
55571
+ rulesyncSubagent,
55572
+ toolTarget: "zcode"
55573
+ });
55574
+ }
55575
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true, global = false }) {
55576
+ const dirPath = relativeDirPath ?? this.getSettablePaths({ global }).relativeDirPath;
55577
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
55578
+ const fileContent = await readFileContent(filePath);
55579
+ const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
55580
+ const result = ZcodeSubagentFrontmatterSchema.safeParse(frontmatter);
55581
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
55582
+ return new ZcodeSubagent({
55583
+ outputRoot,
55584
+ relativeDirPath: dirPath,
55585
+ relativeFilePath,
55586
+ frontmatter: result.data,
55587
+ body: content.trim(),
55588
+ fileContent,
55589
+ validate,
55590
+ global
55591
+ });
55592
+ }
55593
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
55594
+ return new ZcodeSubagent({
55595
+ outputRoot,
55596
+ relativeDirPath,
55597
+ relativeFilePath,
55598
+ frontmatter: {
55599
+ name: "",
55600
+ description: ""
55601
+ },
55602
+ body: "",
55603
+ fileContent: "",
55604
+ validate: false
55605
+ });
55606
+ }
55607
+ };
55608
+ //#endregion
54635
55609
  //#region src/features/subagents/zoocode-subagent.ts
54636
55610
  /**
54637
55611
  * Subagent (custom-mode) generator for **Zoo Code**, the community
@@ -54696,6 +55670,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54696
55670
  ["agentsmd", {
54697
55671
  class: AgentsmdSubagent,
54698
55672
  meta: {
55673
+ supportsProject: true,
54699
55674
  supportsSimulated: true,
54700
55675
  supportsGlobal: false,
54701
55676
  filePattern: "*.md"
@@ -54704,6 +55679,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54704
55679
  ["antigravity-cli", {
54705
55680
  class: AntigravityCliSubagent,
54706
55681
  meta: {
55682
+ supportsProject: true,
54707
55683
  supportsSimulated: false,
54708
55684
  supportsGlobal: true,
54709
55685
  filePattern: "*.md"
@@ -54712,6 +55688,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54712
55688
  ["antigravity-ide", {
54713
55689
  class: AntigravityIdeSubagent,
54714
55690
  meta: {
55691
+ supportsProject: true,
54715
55692
  supportsSimulated: false,
54716
55693
  supportsGlobal: true,
54717
55694
  filePattern: "*.md"
@@ -54720,6 +55697,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54720
55697
  ["antigravity-plugin", {
54721
55698
  class: AntigravityPluginSubagent,
54722
55699
  meta: {
55700
+ supportsProject: true,
54723
55701
  supportsSimulated: false,
54724
55702
  supportsGlobal: false,
54725
55703
  filePattern: "*.md"
@@ -54728,6 +55706,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54728
55706
  ["augmentcode", {
54729
55707
  class: AugmentcodeSubagent,
54730
55708
  meta: {
55709
+ supportsProject: true,
54731
55710
  supportsSimulated: false,
54732
55711
  supportsGlobal: true,
54733
55712
  filePattern: "*.md"
@@ -54736,6 +55715,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54736
55715
  ["claudecode", {
54737
55716
  class: ClaudecodeSubagent,
54738
55717
  meta: {
55718
+ supportsProject: true,
54739
55719
  supportsSimulated: false,
54740
55720
  supportsGlobal: true,
54741
55721
  filePattern: "*.md"
@@ -54744,6 +55724,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54744
55724
  ["claudecode-plugin", {
54745
55725
  class: ClaudecodePluginSubagent,
54746
55726
  meta: {
55727
+ supportsProject: true,
54747
55728
  supportsSimulated: false,
54748
55729
  supportsGlobal: false,
54749
55730
  filePattern: "*.md"
@@ -54752,6 +55733,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54752
55733
  ["claudecode-legacy", {
54753
55734
  class: ClaudecodeSubagent,
54754
55735
  meta: {
55736
+ supportsProject: true,
54755
55737
  supportsSimulated: false,
54756
55738
  supportsGlobal: true,
54757
55739
  filePattern: "*.md"
@@ -54760,6 +55742,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54760
55742
  ["cline", {
54761
55743
  class: ClineSubagent,
54762
55744
  meta: {
55745
+ supportsProject: true,
54763
55746
  supportsSimulated: false,
54764
55747
  supportsGlobal: true,
54765
55748
  filePattern: "*.{yaml,yml}"
@@ -54768,6 +55751,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54768
55751
  ["codexcli", {
54769
55752
  class: CodexCliSubagent,
54770
55753
  meta: {
55754
+ supportsProject: true,
54771
55755
  supportsSimulated: false,
54772
55756
  supportsGlobal: true,
54773
55757
  filePattern: "*.toml"
@@ -54776,6 +55760,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54776
55760
  ["copilot", {
54777
55761
  class: CopilotSubagent,
54778
55762
  meta: {
55763
+ supportsProject: true,
54779
55764
  supportsSimulated: false,
54780
55765
  supportsGlobal: true,
54781
55766
  filePattern: "*.md"
@@ -54784,6 +55769,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54784
55769
  ["copilotcli", {
54785
55770
  class: CopilotcliSubagent,
54786
55771
  meta: {
55772
+ supportsProject: true,
54787
55773
  supportsSimulated: false,
54788
55774
  supportsGlobal: true,
54789
55775
  filePattern: "*.agent.md"
@@ -54792,6 +55778,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54792
55778
  ["cursor", {
54793
55779
  class: CursorSubagent,
54794
55780
  meta: {
55781
+ supportsProject: true,
54795
55782
  supportsSimulated: false,
54796
55783
  supportsGlobal: true,
54797
55784
  filePattern: "*.md"
@@ -54800,6 +55787,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54800
55787
  ["deepagents", {
54801
55788
  class: DeepagentsSubagent,
54802
55789
  meta: {
55790
+ supportsProject: true,
54803
55791
  supportsSimulated: false,
54804
55792
  supportsGlobal: true,
54805
55793
  filePattern: join("*", "AGENTS.md")
@@ -54808,6 +55796,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54808
55796
  ["devin", {
54809
55797
  class: DevinSubagent,
54810
55798
  meta: {
55799
+ supportsProject: true,
54811
55800
  supportsSimulated: false,
54812
55801
  supportsGlobal: true,
54813
55802
  filePattern: join("*", "AGENT.md")
@@ -54816,6 +55805,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54816
55805
  ["factorydroid", {
54817
55806
  class: FactorydroidSubagent,
54818
55807
  meta: {
55808
+ supportsProject: true,
54819
55809
  supportsSimulated: false,
54820
55810
  supportsGlobal: true,
54821
55811
  filePattern: "*.md"
@@ -54824,6 +55814,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54824
55814
  ["goose", {
54825
55815
  class: GooseSubagent,
54826
55816
  meta: {
55817
+ supportsProject: true,
54827
55818
  supportsSimulated: false,
54828
55819
  supportsGlobal: true,
54829
55820
  filePattern: "*.md"
@@ -54832,6 +55823,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54832
55823
  ["hermesagent", {
54833
55824
  class: HermesagentSubagent,
54834
55825
  meta: {
55826
+ supportsProject: true,
54835
55827
  supportsGlobal: true,
54836
55828
  supportsSimulated: false,
54837
55829
  filePattern: "*.json"
@@ -54840,6 +55832,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54840
55832
  ["grokcli", {
54841
55833
  class: GrokcliSubagent,
54842
55834
  meta: {
55835
+ supportsProject: true,
54843
55836
  supportsSimulated: false,
54844
55837
  supportsGlobal: true,
54845
55838
  filePattern: "*.md"
@@ -54848,6 +55841,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54848
55841
  ["junie", {
54849
55842
  class: JunieSubagent,
54850
55843
  meta: {
55844
+ supportsProject: true,
54851
55845
  supportsSimulated: false,
54852
55846
  supportsGlobal: true,
54853
55847
  filePattern: "*.md"
@@ -54856,6 +55850,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54856
55850
  ["kiro", {
54857
55851
  class: KiroSubagent,
54858
55852
  meta: {
55853
+ supportsProject: true,
54859
55854
  supportsSimulated: false,
54860
55855
  supportsGlobal: false,
54861
55856
  filePattern: "*.json"
@@ -54864,6 +55859,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54864
55859
  ["kiro-cli", {
54865
55860
  class: KiroCliSubagent,
54866
55861
  meta: {
55862
+ supportsProject: true,
54867
55863
  supportsSimulated: false,
54868
55864
  supportsGlobal: true,
54869
55865
  filePattern: "*.json"
@@ -54872,6 +55868,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54872
55868
  ["kiro-ide", {
54873
55869
  class: KiroIdeSubagent,
54874
55870
  meta: {
55871
+ supportsProject: true,
54875
55872
  supportsSimulated: false,
54876
55873
  supportsGlobal: true,
54877
55874
  filePattern: "*.md"
@@ -54880,6 +55877,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54880
55877
  ["kilo", {
54881
55878
  class: KiloSubagent,
54882
55879
  meta: {
55880
+ supportsProject: true,
54883
55881
  supportsSimulated: false,
54884
55882
  supportsGlobal: true,
54885
55883
  filePattern: "*.md"
@@ -54888,6 +55886,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54888
55886
  ["kimi-code", {
54889
55887
  class: KimiCodeSubagent,
54890
55888
  meta: {
55889
+ supportsProject: true,
54891
55890
  supportsSimulated: false,
54892
55891
  supportsGlobal: true,
54893
55892
  filePattern: join("**", "*.md")
@@ -54896,6 +55895,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54896
55895
  ["opencode", {
54897
55896
  class: OpenCodeSubagent,
54898
55897
  meta: {
55898
+ supportsProject: true,
54899
55899
  supportsSimulated: false,
54900
55900
  supportsGlobal: true,
54901
55901
  filePattern: "*.md"
@@ -54904,6 +55904,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54904
55904
  ["qwencode", {
54905
55905
  class: QwencodeSubagent,
54906
55906
  meta: {
55907
+ supportsProject: true,
54907
55908
  supportsSimulated: false,
54908
55909
  supportsGlobal: true,
54909
55910
  filePattern: "*.md"
@@ -54912,6 +55913,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54912
55913
  ["reasonix", {
54913
55914
  class: ReasonixSubagent,
54914
55915
  meta: {
55916
+ supportsProject: true,
54915
55917
  supportsSimulated: false,
54916
55918
  supportsGlobal: true,
54917
55919
  filePattern: join("*", "SKILL.md")
@@ -54920,6 +55922,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54920
55922
  ["roo", {
54921
55923
  class: RooSubagent,
54922
55924
  meta: {
55925
+ supportsProject: true,
54923
55926
  supportsSimulated: false,
54924
55927
  supportsGlobal: false,
54925
55928
  filePattern: ".roomodes"
@@ -54928,6 +55931,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54928
55931
  ["zoocode", {
54929
55932
  class: ZoocodeSubagent,
54930
55933
  meta: {
55934
+ supportsProject: true,
54931
55935
  supportsSimulated: false,
54932
55936
  supportsGlobal: false,
54933
55937
  filePattern: ".roomodes"
@@ -54936,6 +55940,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54936
55940
  ["rovodev", {
54937
55941
  class: RovodevSubagent,
54938
55942
  meta: {
55943
+ supportsProject: true,
54939
55944
  supportsSimulated: false,
54940
55945
  supportsGlobal: true,
54941
55946
  filePattern: "*.md"
@@ -54944,6 +55949,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54944
55949
  ["takt", {
54945
55950
  class: TaktSubagent,
54946
55951
  meta: {
55952
+ supportsProject: true,
54947
55953
  supportsSimulated: false,
54948
55954
  supportsGlobal: true,
54949
55955
  filePattern: "*.md"
@@ -54952,10 +55958,20 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54952
55958
  ["vibe", {
54953
55959
  class: VibeSubagent,
54954
55960
  meta: {
55961
+ supportsProject: true,
54955
55962
  supportsSimulated: false,
54956
55963
  supportsGlobal: true,
54957
55964
  filePattern: "*.toml"
54958
55965
  }
55966
+ }],
55967
+ ["zcode", {
55968
+ class: ZcodeSubagent,
55969
+ meta: {
55970
+ supportsProject: false,
55971
+ supportsSimulated: false,
55972
+ supportsGlobal: true,
55973
+ filePattern: "*.md"
55974
+ }
54959
55975
  }]
54960
55976
  ]);
54961
55977
  const defaultGetFactory$1 = (target) => {
@@ -54964,7 +55980,9 @@ const defaultGetFactory$1 = (target) => {
54964
55980
  return factory;
54965
55981
  };
54966
55982
  const allToolTargetKeys$1 = [...toolSubagentFactories.keys()];
54967
- const subagentsProcessorToolTargets = allToolTargetKeys$1;
55983
+ const subagentsProcessorToolTargets = allToolTargetKeys$1.filter((target) => {
55984
+ return toolSubagentFactories.get(target)?.meta.supportsProject ?? false;
55985
+ });
54968
55986
  const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((target) => {
54969
55987
  return toolSubagentFactories.get(target)?.meta.supportsSimulated ?? false;
54970
55988
  });
@@ -55064,7 +56082,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
55064
56082
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
55065
56083
  return [];
55066
56084
  }
55067
- const mdFiles = (await listDirectoryFiles(subagentsDir)).filter((file) => file.endsWith(".md"));
56085
+ const mdFiles = (await listDirectoryEntryNames(subagentsDir)).filter((file) => file.endsWith(".md"));
55068
56086
  if (mdFiles.length === 0) {
55069
56087
  this.logger.debug(`No markdown files found in rulesync subagents directory: ${subagentsDir}`);
55070
56088
  return [];
@@ -63610,6 +64628,6 @@ async function importChecksCore(params) {
63610
64628
  return writtenCount;
63611
64629
  }
63612
64630
  //#endregion
63613
- export { SKILL_FILE_NAME as $, RULESYNC_CHECKS_RELATIVE_DIR_PATH as $t, AUGMENTCODE_DIR as A, pathEscapesRoot as At, RulesyncMcp as B, toPosixPath as Bt, CLAUDECODE_SKILLS_DIR_PATH as C, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as Cn, findFilesByGlobs as Ct, FACTORYDROID_DIR as D, DEPRECATED_FEATURE_REPLACEMENTS as Dn, isSymlink as Dt, CODEXCLI_DIR as E, ALL_FEATURES_WITH_WILDCARD as En, isFileNotFoundError as Et, RulesyncSkill as F, removeFile as Ft, parseJsonc as G, stripHiddenCharacters as Gt, RulesyncHooks as H, writeFileContent as Ht, RulesyncSkillFrontmatterSchema as I, removeFileStrict as It, RulesyncCheck as J, PACKAGING_TOOL_TARGETS as Jt, RulesyncCommand as K, ALL_TOOL_TARGETS as Kt, RulesyncRule as L, removeTempDirectory as Lt, getLocalSkillDirNames as M, readFileContentOrNull as Mt, RulesyncSubagent as N, removeDirectory as Nt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as O, formatError as On, listDirectoryFiles as Ot, RulesyncSubagentFrontmatterSchema as P, removeDirectoryStrict as Pt, SHARED_USER_MANAGED_CONFIG_PATHS as Q, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Qt, RulesyncRuleFrontmatterSchema as R, resolvePath as Rt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as S, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Sn, fileExists as St, CODEXCLI_BASH_RULES_FILE_NAME as T, ALL_FEATURES as Tn, getHomeDirectory as Tt, getRulesyncSourceCandidates as U, hasDeceptiveHiddenCharacters as Ut, RulesyncIgnore as V, writeFileBuffer as Vt, resolveRulesyncSourceWritePath as W, stripControlCharacters as Wt, stringifyFrontmatter as X, MAX_FILE_SIZE as Xt, RulesyncCheckFrontmatterSchema as Y, ToolTargetSchema as Yt, loadYaml as Z, RULESYNC_AIIGNORE_FILE_NAME as Zt, QWENCODE_DIR as _, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as _n, assertWritablePathInsideRoot as _t, getProcessorRegistryEntry as a, RULESYNC_HOOKS_FILE_NAME as an, GITIGNORE_DESTINATION_KEY as at, CLAUDECODE_LOCAL_RULE_FILE_NAME as b, RULESYNC_RULES_RELATIVE_DIR_PATH as bn, directoryExists as bt, RulesProcessor as c, RULESYNC_IGNORE_RELATIVE_FILE_PATH as cn, ConsoleLogger as ct, displayWidthOf as d, RULESYNC_MCP_LEGACY_FILE_NAME as dn, warnOnConflictingFlags as dt, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as en, ConfigResolver as et, shortenToWidth as f, RULESYNC_MCP_RELATIVE_FILE_PATH as fn, resetWarnedOnceMessages as ft, CommandsProcessor as g, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as gn, assertTreeContainsNoSymlinks as gt, HooksProcessor as h, RULESYNC_PERMISSIONS_FILE_NAME as hn, assertDirectoryIfExists as ht, inspectInputRoots as i, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as in, ConfigFileSchema as it, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as j, readFileContent as jt, caseFoldIdentity as k, listSubdirectoryNames as kt, SubagentsProcessor as l, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as ln, JsonLogger as lt, IgnoreProcessor as m, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as mn, ErrorCodes as mt, formatSourceLoadFailure as n, RULESYNC_CONFIG_SCHEMA_URL as nn, resolveEffectiveInputRoots as nt, convertFromTool as o, RULESYNC_HOOKS_LEGACY_FILE_NAME as on, SourceEntrySchema as ot, McpProcessor as p, RULESYNC_MCP_SCHEMA_URL as pn, CLIError as pt, RulesyncCommandFrontmatterSchema as q, ALL_TOOL_TARGETS_WITH_WILDCARD as qt, generate as r, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as rn, CONFLICTING_TARGET_PAIRS as rt, isPackagingToolTarget as s, RULESYNC_HOOKS_RELATIVE_FILE_PATH as sn, findControlCharacter as st, importFromTool as t, RULESYNC_CONFIG_RELATIVE_FILE_PATH as tn, mergeInputRootConfigs as tt, SkillsProcessor as u, RULESYNC_MCP_FILE_NAME as un, fallbackLogger as ut, QWENCODE_LOCAL_RULE_FILE_NAME as v, RULESYNC_PERMISSIONS_SCHEMA_URL as vn, checkPathTraversal as vt, ChecksProcessor as w, parseCommaSeparatedList as wn, getFileSize as wt, CLAUDECODE_MEMORIES_DIR_NAME as x, RULESYNC_SKILLS_RELATIVE_DIR_PATH as xn, ensureDir as xt, CLAUDECODE_DIR as y, RULESYNC_RELATIVE_DIR_PATH as yn, createTempDirectory as yt, RulesyncPermissions as z, runWithDirectoryRollback as zt };
64631
+ export { SHARED_USER_MANAGED_CONFIG_PATHS as $, MAX_FILE_SIZE as $t, groupSpellingsByCaseFoldedIdentity as A, DEPRECATED_FEATURE_REPLACEMENTS as An, isFileSystemError as At, RulesyncPermissions as B, removeFile as Bt, CLAUDECODE_SKILLS_DIR_PATH as C, RULESYNC_RULES_RELATIVE_DIR_PATH as Cn, createTempDirectory as Ct, FACTORYDROID_DIR as D, parseCommaSeparatedList as Dn, getFileSize as Dt, CODEXCLI_DIR as E, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as En, fileExists as Et, RulesyncSubagentFrontmatterSchema as F, stripControlCharactersKeepingLineFeeds as Fn, pathEscapesRoot as Ft, resolveRulesyncSourceWritePath as G, toPosixPath as Gt, RulesyncIgnore as H, removeTempDirectory as Ht, RulesyncSkill as I, stripHiddenCharacters as In, readFileContent as It, RulesyncCommandFrontmatterSchema as J, ALL_TOOL_TARGETS as Jt, parseJsonc as K, writeFileBuffer as Kt, RulesyncSkillFrontmatterSchema as L, readFileContentOrNull as Lt, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as M, truncateText as Mn, listDirectoryEntryNames as Mt, getLocalSkillDirNames as N, hasDeceptiveHiddenCharacters as Nn, listFilePathsRecursively as Nt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as O, ALL_FEATURES as On, getHomeDirectory as Ot, RulesyncSubagent as P, stripControlCharacters as Pn, listSubdirectoryNames as Pt, loadYaml as Q, CURATED_RULES_FEATURE_SUBDIR as Qt, RulesyncRule as R, removeDirectory as Rt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as S, RULESYNC_RELATIVE_DIR_PATH as Sn, checkPathTraversal as St, CODEXCLI_BASH_RULES_FILE_NAME as T, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Tn, ensureDir as Tt, RulesyncHooks as U, resolvePath as Ut, RulesyncMcp as V, removeFileStrict as Vt, getRulesyncSourceCandidates as W, runWithDirectoryRollback as Wt, RulesyncCheckFrontmatterSchema as X, PACKAGING_TOOL_TARGETS as Xt, RulesyncCheck as Y, ALL_TOOL_TARGETS_WITH_WILDCARD as Yt, stringifyFrontmatter as Z, ToolTargetSchema as Zt, QWENCODE_DIR as _, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as _n, CLIError as _t, getProcessorRegistryEntry as a, RULESYNC_CONFIG_SCHEMA_URL as an, ConfigFileSchema as at, CLAUDECODE_LOCAL_RULE_FILE_NAME as b, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as bn, assertTreeContainsNoSymlinks as bt, RulesProcessor as c, RULESYNC_HOOKS_FILE_NAME as cn, findControlCharacter as ct, displayWidthOf as d, RULESYNC_IGNORE_RELATIVE_FILE_PATH as dn, WarningCollectingLogger as dt, RULESYNC_AIIGNORE_FILE_NAME as en, SKILL_FILE_NAME as et, shortenToWidth as f, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as fn, fallbackLogger as ft, CommandsProcessor as g, RULESYNC_MCP_SCHEMA_URL as gn, withWarnOnceScope as gt, HooksProcessor as h, RULESYNC_MCP_RELATIVE_FILE_PATH as hn, resetWarnedOnceMessages as ht, inspectInputRoots as i, RULESYNC_CONFIG_RELATIVE_FILE_PATH as in, CONFLICTING_TARGET_PAIRS as it, AUGMENTCODE_DIR as j, formatError as jn, isSymlink as jt, caseFoldIdentity as k, ALL_FEATURES_WITH_WILDCARD as kn, isFileNotFoundError as kt, SubagentsProcessor as l, RULESYNC_HOOKS_LEGACY_FILE_NAME as ln, ConsoleLogger as lt, IgnoreProcessor as m, RULESYNC_MCP_LEGACY_FILE_NAME as mn, withFallbackLoggerTarget as mt, formatSourceLoadFailure as n, RULESYNC_CHECKS_RELATIVE_DIR_PATH as nn, mergeInputRootConfigs as nt, convertFromTool as o, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as on, GITIGNORE_DESTINATION_KEY as ot, McpProcessor as p, RULESYNC_MCP_FILE_NAME as pn, warnOnConflictingFlags as pt, RulesyncCommand as q, writeFileContent as qt, generate as r, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as rn, resolveEffectiveInputRoots as rt, isPackagingToolTarget as s, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as sn, SourceEntrySchema as st, importFromTool as t, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as tn, ConfigResolver as tt, SkillsProcessor as u, RULESYNC_HOOKS_RELATIVE_FILE_PATH as un, JsonLogger as ut, QWENCODE_LOCAL_RULE_FILE_NAME as v, RULESYNC_PERMISSIONS_FILE_NAME as vn, ErrorCodes as vt, ChecksProcessor as w, RULESYNC_SKILLS_RELATIVE_DIR_PATH as wn, directoryExists as wt, CLAUDECODE_MEMORIES_DIR_NAME as x, RULESYNC_PERMISSIONS_SCHEMA_URL as xn, assertWritablePathInsideRoot as xt, CLAUDECODE_DIR as y, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as yn, assertDirectoryIfExists as yt, RulesyncRuleFrontmatterSchema as z, removeDirectoryStrict as zt };
63614
64632
 
63615
- //# sourceMappingURL=import-BKnAc4rT.js.map
64633
+ //# sourceMappingURL=import-DijDR24m.js.map