rulesync 16.18.0 → 16.20.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.
@@ -2,19 +2,225 @@ import { ZodError } from "zod";
2
2
  import { meta, minLength, nonnegative, optional, refine, z } from "zod/mini";
3
3
  import { chmod, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
4
4
  import path, { basename, dirname, extname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
5
- import { parse, parseTree, printParseErrorCode } from "jsonc-parser";
5
+ import { applyEdits, findNodeAtLocation, getNodeValue, modify, 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).
1633
+ */
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.
1361
1642
  */
1362
- const warnedOnceMessages = /* @__PURE__ */ new Set();
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
@@ -10009,11 +10631,30 @@ const CODEXCLI_OVERRIDE_KEYS = [
10009
10631
  ];
10010
10632
  //#endregion
10011
10633
  //#region src/features/shared/shared-config-gateway.ts
10634
+ /**
10635
+ * Rebuild a parsed document without its prototype-pollution keys.
10636
+ *
10637
+ * Every object is rebuilt, not just the ones that are already plain: a literal
10638
+ * `"__proto__"` is assigned with `obj[key] = value` by `jsonc-parser`, which
10639
+ * *replaces the containing object's prototype* instead of adding a key. Such
10640
+ * an object is no longer a plain object, and the injected value is reachable
10641
+ * through it by plain property access while `Object.keys` and `JSON.stringify`
10642
+ * show nothing — so leaving it as it came out of the parser would both hide a
10643
+ * server or permission the file does not state and, at the root, cost the
10644
+ * whole document (see {@link parseSharedConfig}). Rebuilding gives every
10645
+ * object `Object.prototype` back and drops the injected value with the key.
10646
+ *
10647
+ * Dates are the one object the YAML and TOML parsers produce that is not a
10648
+ * mapping, so they are passed through rather than flattened into `{}`.
10649
+ */
10012
10650
  function sanitizeSharedConfigValue(value) {
10013
10651
  if (Array.isArray(value)) return value.map(sanitizeSharedConfigValue);
10014
- if (!isPlainObject$1(value)) return value;
10652
+ if (value === null || typeof value !== "object" || value instanceof Date) return value;
10015
10653
  const result = {};
10016
- for (const [key, nested] of Object.entries(omitPrototypePollutionKeys(value))) result[key] = sanitizeSharedConfigValue(nested);
10654
+ for (const [key, nested] of Object.entries(value)) {
10655
+ if (isPrototypePollutionKey(key)) continue;
10656
+ result[key] = sanitizeSharedConfigValue(nested);
10657
+ }
10017
10658
  return result;
10018
10659
  }
10019
10660
  /**
@@ -10042,11 +10683,12 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
10042
10683
  throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
10043
10684
  }
10044
10685
  if (parsed === void 0 || parsed === null) return {};
10045
- if (!isPlainObject$1(parsed)) {
10686
+ const sanitized = sanitizeSharedConfigValue(parsed);
10687
+ if (!isPlainObject$1(sanitized)) {
10046
10688
  if (invalidRootPolicy === "error") throw new Error(`Failed to parse shared config${at}: expected a mapping at the root`);
10047
10689
  return {};
10048
10690
  }
10049
- return sanitizeSharedConfigValue(parsed);
10691
+ return sanitized;
10050
10692
  }
10051
10693
  /**
10052
10694
  * Serialize a shared config document. YAML output always ends with exactly one
@@ -10063,6 +10705,571 @@ function stringifySharedConfig({ format, document }) {
10063
10705
  return JSON.stringify(document, null, 2);
10064
10706
  }
10065
10707
  /**
10708
+ * Where the line holding `from` starts and where the line starting at `from`
10709
+ * ends: the nearest `\r` or `\n` in each direction, or the edge of the text.
10710
+ *
10711
+ * Both characters count in both directions, because the JSONC scanner treats
10712
+ * either as a line break. A file written with lone CRs parses without an error
10713
+ * and so reaches these paths, and a comment read only up to the next `\n`
10714
+ * would run past its own line and take the closing brace — or a whole sibling
10715
+ * key — with it.
10716
+ */
10717
+ function endOfLineFrom({ text, from }) {
10718
+ for (let index = from; index < text.length; index += 1) {
10719
+ const character = text[index];
10720
+ if (character === "\n" || character === "\r") return index;
10721
+ }
10722
+ return text.length;
10723
+ }
10724
+ function startOfLineAt({ text, from }) {
10725
+ for (let index = from - 1; index >= 0; index -= 1) {
10726
+ const character = text[index];
10727
+ if (character === "\n" || character === "\r") return index + 1;
10728
+ }
10729
+ return 0;
10730
+ }
10731
+ /**
10732
+ * Read the indentation and line ending a JSONC document already uses, so
10733
+ * inserted properties match the surrounding file instead of imposing the
10734
+ * 2-space `JSON.stringify` shape on a file written with 4 spaces or tabs.
10735
+ *
10736
+ * The root object's first property decides, located through the syntax tree
10737
+ * rather than by scanning for the first indented line: a file opening with a
10738
+ * banner comment indents that comment's continuation lines too (` * ...`
10739
+ * aligns at three columns), and reading the width off one of those would leave
10740
+ * `modify` re-indenting the lines it touches to a width nothing else in the
10741
+ * file uses. A property that does not start its own line — a one-line object,
10742
+ * or an empty one — carries no indent to read, so those fall back to the
10743
+ * 2-space default the whole-document writer emits.
10744
+ */
10745
+ function detectJsoncFormattingOptions({ text, root }) {
10746
+ const eol = "\n";
10747
+ const first = root.children?.[0];
10748
+ const indent = first === void 0 ? "" : text.slice(startOfLineAt({
10749
+ text,
10750
+ from: first.offset
10751
+ }), first.offset);
10752
+ if (indent === "" || indent.length > 8 || !/^[ \t]+$/.test(indent)) return {
10753
+ tabSize: 2,
10754
+ insertSpaces: true,
10755
+ eol
10756
+ };
10757
+ if (indent.includes(" ")) return {
10758
+ tabSize: 2,
10759
+ insertSpaces: false,
10760
+ eol
10761
+ };
10762
+ return {
10763
+ tabSize: indent.length,
10764
+ insertSpaces: true,
10765
+ eol
10766
+ };
10767
+ }
10768
+ /**
10769
+ * Whether the document states a key no edit-based write can be trusted with.
10770
+ *
10771
+ * Two kinds, both answered from the syntax tree because the parsed value no
10772
+ * longer knows about either:
10773
+ *
10774
+ * - A key stated twice. That is legal JSON text which every reader resolves
10775
+ * last-wins, while `modify` edits the *first* occurrence — so an edit-based
10776
+ * write would land on the dead copy and leave the live one saying whatever
10777
+ * it said before. For an owned key that is a silent ownership failure: a
10778
+ * `deny` rulesync just wrote would sit above the `allow` the tool reads.
10779
+ * - `__proto__`, `constructor` or `prototype`. None survives into the parsed
10780
+ * document — a nested one is dropped, a root-level `__proto__` replaces the
10781
+ * root's prototype — so an edit-based write would find no difference to
10782
+ * apply and leave the key in the file.
10783
+ *
10784
+ * The whole-document writer resolves duplicates last-wins and drops pollution
10785
+ * keys, which is what it has always done, so those files go to it.
10786
+ */
10787
+ function statesUneditableKeys(node) {
10788
+ if (node.type === "array") return (node.children ?? []).some((child) => statesUneditableKeys(child));
10789
+ if (node.type !== "object") return false;
10790
+ const seen = /* @__PURE__ */ new Set();
10791
+ for (const property of node.children ?? []) {
10792
+ const key = property.children?.[0]?.value;
10793
+ if (typeof key === "string") {
10794
+ if (seen.has(key) || isPrototypePollutionKey(key)) return true;
10795
+ seen.add(key);
10796
+ }
10797
+ const value = property.children?.[1];
10798
+ if (value !== void 0 && statesUneditableKeys(value)) return true;
10799
+ }
10800
+ return false;
10801
+ }
10802
+ /**
10803
+ * The offset just past the whitespace and comments starting at `from`.
10804
+ */
10805
+ function skipJsoncTrivia({ text, from }) {
10806
+ let index = from;
10807
+ while (index < text.length) {
10808
+ const char = text[index];
10809
+ if (char === " " || char === " " || char === "\n" || char === "\r") {
10810
+ index += 1;
10811
+ continue;
10812
+ }
10813
+ if (char === "/" && text[index + 1] === "/") {
10814
+ index = endOfLineFrom({
10815
+ text,
10816
+ from: index
10817
+ });
10818
+ continue;
10819
+ }
10820
+ if (char === "/" && text[index + 1] === "*") {
10821
+ const commentEnd = text.indexOf("*/", index + 2);
10822
+ index = commentEnd === -1 ? text.length : commentEnd + 2;
10823
+ continue;
10824
+ }
10825
+ break;
10826
+ }
10827
+ return index;
10828
+ }
10829
+ /**
10830
+ * Where the deletion of a property whose text ends at `end` should stop.
10831
+ *
10832
+ * A comment written after the property on its own line is that property's
10833
+ * note — `"stale": {...}, // retired` says something about `stale` and nothing
10834
+ * about the key above it. Leaving it behind would re-attach it to whichever
10835
+ * property now ends that line, so a note about a server rulesync removed would
10836
+ * read as a note about the one before it. A comment with a sibling after it on
10837
+ * the same line is not claimed: it may belong to either.
10838
+ */
10839
+ function endOfRemoval({ text, end }) {
10840
+ const stop = endOfLineFrom({
10841
+ text,
10842
+ from: end
10843
+ });
10844
+ const tail = text.slice(end, stop);
10845
+ return /^[ \t]*\/\/[^\r\n]*$/.test(tail) || /^[ \t]*\/\*(?:[^*]|\*(?!\/))*\*\/[ \t]*$/.test(tail) || /^[ \t]*$/.test(tail) ? stop : end;
10846
+ }
10847
+ /**
10848
+ * Where the deletion of a property ending at `end` should begin.
10849
+ *
10850
+ * A property that has its line to itself is removed with the line: its
10851
+ * indentation and the newline above it would otherwise be left as a blank gap.
10852
+ * A property sharing its line with something else — a sibling, or the object's
10853
+ * own `}` — is removed on its own, because swallowing the newline would splice
10854
+ * whatever follows onto the line above, and a line comment up there would
10855
+ * comment it out: a key rulesync means to write would vanish from the file, or
10856
+ * the closing brace would, leaving the document unparsable.
10857
+ */
10858
+ function startOfRemoval({ text, propertyOffset, end }) {
10859
+ let after = end;
10860
+ while (text[after] === " " || text[after] === " ") after += 1;
10861
+ if (!(after >= text.length || text[after] === "\n" || text[after] === "\r")) return propertyOffset;
10862
+ let start = propertyOffset;
10863
+ while (start > 0 && (text[start - 1] === " " || text[start - 1] === " ")) start -= 1;
10864
+ if (start > 0 && text[start - 1] === "\n") start -= 1;
10865
+ if (start > 0 && text[start - 1] === "\r") start -= 1;
10866
+ return start;
10867
+ }
10868
+ /**
10869
+ * Delete the property at `path` from `text`, taking its own line and its
10870
+ * separating comma but nothing else.
10871
+ *
10872
+ * `modify(..., undefined)` would do this, but the range it deletes runs from
10873
+ * the end of the *previous* property to the end of this one — or, for the
10874
+ * first property of an object, all the way to where the *next* one starts. So
10875
+ * removing one key takes the comments sitting between it and the keys around
10876
+ * it, including the comment describing the key that survives.
10877
+ * Deleting the property's own text instead leaves the comments around it in
10878
+ * place. Only the note that follows the property on its own line goes with it
10879
+ * (see {@link endOfRemoval}); a comment written on the line *above* is left
10880
+ * behind rather than guessed at, which is the direction this whole path errs
10881
+ * in.
10882
+ */
10883
+ function removeJsoncProperty({ text, path }) {
10884
+ const root = parseTree(text, [], { allowTrailingComma: true });
10885
+ const property = root === void 0 ? void 0 : findNodeAtLocation(root, [...path])?.parent;
10886
+ const object = property?.parent;
10887
+ if (property?.type !== "property" || object?.type !== "object") return text;
10888
+ const siblings = object.children ?? [];
10889
+ const edits = [];
10890
+ let end = property.offset + property.length;
10891
+ const afterProperty = skipJsoncTrivia({
10892
+ text,
10893
+ from: end
10894
+ });
10895
+ if (text[afterProperty] === ",") end = afterProperty + 1;
10896
+ else {
10897
+ const previous = siblings[siblings.indexOf(property) - 1];
10898
+ if (previous !== void 0) {
10899
+ const comma = skipJsoncTrivia({
10900
+ text,
10901
+ from: previous.offset + previous.length
10902
+ });
10903
+ if (text[comma] === ",") edits.push({
10904
+ offset: comma,
10905
+ length: 1,
10906
+ content: ""
10907
+ });
10908
+ }
10909
+ }
10910
+ end = endOfRemoval({
10911
+ text,
10912
+ end
10913
+ });
10914
+ const start = startOfRemoval({
10915
+ text,
10916
+ propertyOffset: property.offset,
10917
+ end
10918
+ });
10919
+ edits.push({
10920
+ offset: start,
10921
+ length: end - start,
10922
+ content: ""
10923
+ });
10924
+ return applyEdits(text, edits);
10925
+ }
10926
+ /**
10927
+ * Where the comment written at `from` (past any spaces or tabs) ends, or
10928
+ * `undefined` if what stands there is not a comment.
10929
+ */
10930
+ function endOfNoteAt({ text, from }) {
10931
+ let cursor = from;
10932
+ while (text[cursor] === " " || text[cursor] === " ") cursor += 1;
10933
+ if (text.startsWith("//", cursor)) return endOfLineFrom({
10934
+ text,
10935
+ from: cursor
10936
+ });
10937
+ if (text.startsWith("/*", cursor)) {
10938
+ const closing = text.indexOf("*/", cursor + 2);
10939
+ return closing === -1 ? void 0 : closing + 2;
10940
+ }
10941
+ }
10942
+ /**
10943
+ * Every comment written at `from`, as spans of `text`, each span running from
10944
+ * where the previous one stopped so the whitespace between them is carried
10945
+ * along. A comma is stepped over once (a file may spell one before its note,
10946
+ * or after it) but never collected, because the separator belongs to the
10947
+ * property rather than to its note. So a file that writes a note on each side
10948
+ * of its comma gets both of them back, in order, after the comma: the notes
10949
+ * stay with the key they describe, and the comma keeps the place the file gave
10950
+ * it. The run stops at the first thing that is neither: a newline ends it, so
10951
+ * a comment on the next line is left alone.
10952
+ */
10953
+ function notesAt({ text, from }) {
10954
+ const spans = [];
10955
+ let cursor = from;
10956
+ let steppedOverComma = false;
10957
+ for (;;) {
10958
+ const end = endOfNoteAt({
10959
+ text,
10960
+ from: cursor
10961
+ });
10962
+ if (end !== void 0) {
10963
+ spans.push({
10964
+ start: cursor,
10965
+ end
10966
+ });
10967
+ cursor = end;
10968
+ continue;
10969
+ }
10970
+ if (steppedOverComma) return spans;
10971
+ let comma = cursor;
10972
+ while (text[comma] === " " || text[comma] === " ") comma += 1;
10973
+ if (text[comma] !== ",") return spans;
10974
+ steppedOverComma = true;
10975
+ cursor = comma + 1;
10976
+ }
10977
+ }
10978
+ /**
10979
+ * Detach the notes written at the point where the object at `path` will take a
10980
+ * new key: after its last property (and around the comma a trailing-comma file
10981
+ * spells there), or just inside the `{` when it has no properties yet.
10982
+ *
10983
+ * `modify` computes its insert from exactly that point — in front of a note
10984
+ * written there — so applying the edit unchanged re-emits the note *after* the
10985
+ * key that was just inserted: `"stale": {...} // retired` turns into a note
10986
+ * about a server rulesync has only now written, and `{ /* none yet *\/ }`
10987
+ * turns into a note about the first entry rulesync puts in it. Lifting the
10988
+ * notes out before the insert and putting them back afterwards keeps them
10989
+ * where their author wrote them, matching what {@link endOfRemoval} does on
10990
+ * the way out.
10991
+ *
10992
+ * Returns `undefined` when there is no such note, which is the common case.
10993
+ */
10994
+ function detachTrailingNote({ text, path }) {
10995
+ const root = parseTree(text, [], { allowTrailingComma: true });
10996
+ const object = root === void 0 ? void 0 : findNodeAtLocation(root, [...path]);
10997
+ if (object?.type !== "object") return void 0;
10998
+ const property = object.children?.at(-1);
10999
+ const anchorKey = property?.children?.[0]?.value;
11000
+ if (property !== void 0 && typeof anchorKey !== "string") return void 0;
11001
+ const spans = notesAt({
11002
+ text,
11003
+ from: property === void 0 ? object.offset + 1 : property.offset + property.length
11004
+ });
11005
+ if (spans.length === 0) return void 0;
11006
+ let stripped = text;
11007
+ for (const span of spans.toReversed()) stripped = stripped.slice(0, span.start) + stripped.slice(span.end);
11008
+ return {
11009
+ text: stripped,
11010
+ note: spans.map((span) => text.slice(span.start, span.end)).join(""),
11011
+ anchorKey: typeof anchorKey === "string" ? anchorKey : void 0
11012
+ };
11013
+ }
11014
+ /**
11015
+ * Put a note detached by {@link detachTrailingNote} back where it was: after
11016
+ * the property it describes (behind the comma the insert gave that property),
11017
+ * or just inside the `{` of the object it was written in when there was no
11018
+ * property to describe. Returns `undefined` if that place can no longer be
11019
+ * located, so the caller can fall back to the plain insert rather than drop
11020
+ * the note.
11021
+ */
11022
+ function reattachTrailingNote({ text, path, anchorKey, note }) {
11023
+ const root = parseTree(text, [], { allowTrailingComma: true });
11024
+ const location = anchorKey === void 0 ? [...path] : [...path, anchorKey];
11025
+ const anchor = root === void 0 ? void 0 : findNodeAtLocation(root, location);
11026
+ if (anchor === void 0) return void 0;
11027
+ if (anchorKey === void 0) {
11028
+ if (anchor.type !== "object") return void 0;
11029
+ const brace = anchor.offset + 1;
11030
+ return text.slice(0, brace) + note + text.slice(brace);
11031
+ }
11032
+ let cursor = anchor.offset + anchor.length;
11033
+ while (text[cursor] === " " || text[cursor] === " ") cursor += 1;
11034
+ if (text[cursor] === ",") cursor += 1;
11035
+ return text.slice(0, cursor) + note + text.slice(cursor);
11036
+ }
11037
+ /**
11038
+ * Write `value` at `[...path, key]`, keeping the trailing note of the property
11039
+ * the new key is inserted after (see {@link detachTrailingNote}). Replacing an
11040
+ * existing key needs none of this: `modify` rewrites the value's own span and
11041
+ * leaves every comment where it is.
11042
+ */
11043
+ function insertJsoncProperty({ text, path, key, value, options }) {
11044
+ const write = (source) => applyEdits(source, modify(source, [...path, key], value, options));
11045
+ const detached = detachTrailingNote({
11046
+ text,
11047
+ path
11048
+ });
11049
+ if (detached === void 0) return write(text);
11050
+ return reattachTrailingNote({
11051
+ text: write(detached.text),
11052
+ path,
11053
+ anchorKey: detached.anchorKey,
11054
+ note: detached.note
11055
+ }) ?? write(text);
11056
+ }
11057
+ /**
11058
+ * How much work an edit-based write may cost, as the file's length times the
11059
+ * number of keys that differ.
11060
+ *
11061
+ * Each changed key re-parses the whole file, so a file whose keys nearly all
11062
+ * change costs quadratic work: an 823 KB `opencode.json` whose 6,400 servers
11063
+ * are all replaced took 40 seconds to write as edits, and cloning a
11064
+ * repository that ships such a file is enough to reach that. Past this budget
11065
+ * the file is written whole instead — it loses its comments, the same as a
11066
+ * file rulesync cannot parse does, which is the better of the two outcomes
11067
+ * against a `generate` that looks like it has hung. The limit is under a
11068
+ * second of editing for a replacement and a second or two for an insert,
11069
+ * which parses more; a hand-written config file is orders of magnitude below
11070
+ * either.
11071
+ */
11072
+ const JSONC_EDIT_BUDGET_BYTES = 5e7;
11073
+ /**
11074
+ * How much text the edits of one write may put into the file, all of them
11075
+ * together.
11076
+ *
11077
+ * The budget above charges by the number of keys that differ, which prices an
11078
+ * insert of a whole subtree as one edit — but `modify` re-indents the text it
11079
+ * writes, and it does that in time quadratic in the length of that text: a
11080
+ * 127 KB value takes half a second to write, a 516 KB one 9 seconds, a 1 MB
11081
+ * one 35. The cost is quadratic in the total as well as in each part, because
11082
+ * every edit re-indents against the text the ones before it left, so a limit
11083
+ * on the widest single value would let a handful of values just under it cost
11084
+ * minutes between them. This is a limit on their sum, which holds the
11085
+ * re-indenting to about a second whether it arrives as one value or twenty.
11086
+ * A hand-written config file changes a few hundred bytes at a time.
11087
+ */
11088
+ const JSONC_EDIT_WRITTEN_BYTES = 2e5;
11089
+ /**
11090
+ * How much text one edit writes: the value as `modify` formats it, plus the
11091
+ * indentation that formatting puts in front of every line of it.
11092
+ *
11093
+ * A value written deep in a document is written far wider than it reads on
11094
+ * its own — a 114 KB server list nested 1,200 deep is 36 MB of text once
11095
+ * every line of it carries 2,400 spaces — so measuring the value alone would
11096
+ * miss the whole of what makes that write expensive.
11097
+ */
11098
+ function measureJsoncWrite({ value, depth }) {
11099
+ const formatted = JSON.stringify(value, null, 2) ?? "";
11100
+ let lines = 1;
11101
+ for (let at = formatted.indexOf("\n"); at !== -1; at = formatted.indexOf("\n", at + 1)) lines += 1;
11102
+ return formatted.length + lines * 2 * depth;
11103
+ }
11104
+ /**
11105
+ * How much {@link applyJsoncObjectEdits} would write, deciding each key
11106
+ * exactly the way it does — the two walk together, so a new case in one needs
11107
+ * the same case here. It reports both how many edits there would be and how
11108
+ * much text they would write between them, because the two are budgeted
11109
+ * separately: one stands for the parse each edit costs, the other for the
11110
+ * re-indenting each edit costs. Counting is a walk of the documents alone: no
11111
+ * text is touched, so the budgets above are spent on the write rather than on
11112
+ * finding out how large the write is.
11113
+ */
11114
+ function countJsoncEdits({ base, next, depth }) {
11115
+ let edits = 0;
11116
+ let written = 0;
11117
+ for (const [key, value] of Object.entries(next)) {
11118
+ if (isPrototypePollutionKey(key)) continue;
11119
+ const present = Object.hasOwn(base, key);
11120
+ const previous = present ? base[key] : void 0;
11121
+ if (value === void 0) {
11122
+ if (present) edits += 1;
11123
+ continue;
11124
+ }
11125
+ if (isPlainObject$1(previous) && isPlainObject$1(value)) {
11126
+ const nested = countJsoncEdits({
11127
+ base: previous,
11128
+ next: value,
11129
+ depth: depth + 1
11130
+ });
11131
+ edits += nested.edits;
11132
+ written += nested.written;
11133
+ continue;
11134
+ }
11135
+ if (present && isDeepStrictEqual(previous, value)) continue;
11136
+ edits += 1;
11137
+ written += measureJsoncWrite({
11138
+ value,
11139
+ depth: depth + 1
11140
+ });
11141
+ }
11142
+ for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) edits += 1;
11143
+ return {
11144
+ edits,
11145
+ written
11146
+ };
11147
+ }
11148
+ /**
11149
+ * Rewrite `text` so the object at `path` matches `next`, touching only the
11150
+ * spans that actually differ from `base`.
11151
+ *
11152
+ * Each difference is applied on its own — `modify` computes an edit against
11153
+ * the current text and `applyEdits` returns the text with that edit applied,
11154
+ * which is then the input for the next difference, because every edit shifts
11155
+ * the offsets the following ones would have been computed from. Nested objects
11156
+ * present on both sides are recursed into rather than replaced wholesale, so a
11157
+ * one-key change deep in the document leaves its siblings — and the comments
11158
+ * attached to them — byte-identical.
11159
+ *
11160
+ * Every difference re-parses the document, so the work is one parse of the
11161
+ * file per *changed* key rather than one parse overall. A regeneration that
11162
+ * changes nothing costs a single parse, and the files this runs on are config
11163
+ * files, so the shape is left simple rather than batched — with the file
11164
+ * large enough and enough of it changing, the product of the two is what
11165
+ * {@link JSONC_EDIT_BUDGET_BYTES} keeps off this path.
11166
+ */
11167
+ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11168
+ let result = text;
11169
+ for (const [key, value] of Object.entries(next)) {
11170
+ if (isPrototypePollutionKey(key)) continue;
11171
+ const present = Object.hasOwn(base, key);
11172
+ const previous = present ? base[key] : void 0;
11173
+ if (value === void 0) {
11174
+ if (present) result = removeJsoncProperty({
11175
+ text: result,
11176
+ path: [...path, key]
11177
+ });
11178
+ continue;
11179
+ }
11180
+ if (isPlainObject$1(previous) && isPlainObject$1(value)) {
11181
+ result = applyJsoncObjectEdits({
11182
+ text: result,
11183
+ base: previous,
11184
+ next: value,
11185
+ path: [...path, key],
11186
+ options
11187
+ });
11188
+ continue;
11189
+ }
11190
+ if (present) {
11191
+ if (isDeepStrictEqual(previous, value)) continue;
11192
+ result = applyEdits(result, modify(result, [...path, key], value, options));
11193
+ continue;
11194
+ }
11195
+ result = insertJsoncProperty({
11196
+ text: result,
11197
+ path,
11198
+ key,
11199
+ value,
11200
+ options
11201
+ });
11202
+ }
11203
+ for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) result = removeJsoncProperty({
11204
+ text: result,
11205
+ path: [...path, key]
11206
+ });
11207
+ return result;
11208
+ }
11209
+ /**
11210
+ * Serialize a document back over the file it was parsed from.
11211
+ *
11212
+ * For every format but JSONC this is {@link stringifySharedConfig}: those
11213
+ * files carry no comments, so re-serializing loses nothing. A JSONC file does
11214
+ * carry comments — `.vscode/settings.json` and `opencode.json` are hand-edited
11215
+ * far more often than they are generated — and re-serializing would delete
11216
+ * every one of them, along with the author's blank lines and key order. So a
11217
+ * JSONC document is written back as a set of edits against the existing text:
11218
+ * regions the merge did not change stay byte-identical, and a regeneration
11219
+ * that changes nothing leaves the file untouched.
11220
+ *
11221
+ * The whole-document writer is still used when there is nothing to preserve or
11222
+ * nothing to edit against:
11223
+ *
11224
+ * - an empty (or whitespace-only) file, which has no comments to keep;
11225
+ * - a file that does not parse, or whose root is not an object — editing it
11226
+ * would mean guessing at the author's intent, and the callers that reach
11227
+ * here have already decided (via `invalidRootPolicy`) that such a file is
11228
+ * replaced;
11229
+ * - a file stating the same key twice, or using `__proto__`, `constructor` or
11230
+ * `prototype` as a key (see {@link statesUneditableKeys});
11231
+ * - a file so large, with so much of it changing, that editing it key by key
11232
+ * would take longer than a user would wait (see
11233
+ * {@link JSONC_EDIT_BUDGET_BYTES}), or changed keys that write more new text
11234
+ * between them than re-indenting can afford (see
11235
+ * {@link JSONC_EDIT_WRITTEN_BYTES});
11236
+ * - a file the editor itself refuses, which it answers with an exception
11237
+ * rather than a result.
11238
+ */
11239
+ function serializeSharedConfig({ format, document, existingContent }) {
11240
+ const whole = stringifySharedConfig({
11241
+ format,
11242
+ document
11243
+ });
11244
+ if (format !== "jsonc" || existingContent.trim() === "") return whole;
11245
+ try {
11246
+ const errors = [];
11247
+ const root = parseTree(existingContent, errors, { allowTrailingComma: true });
11248
+ if (root === void 0 || errors.length > 0 || root.type !== "object" || statesUneditableKeys(root)) return whole;
11249
+ const base = sanitizeSharedConfigValue(getNodeValue(root));
11250
+ if (!isPlainObject$1(base)) return whole;
11251
+ const span = Math.max(existingContent.length, whole.length);
11252
+ const cost = countJsoncEdits({
11253
+ base,
11254
+ next: document,
11255
+ depth: 0
11256
+ });
11257
+ if (cost.written > JSONC_EDIT_WRITTEN_BYTES || cost.edits * span > JSONC_EDIT_BUDGET_BYTES) return whole;
11258
+ return applyJsoncObjectEdits({
11259
+ text: existingContent,
11260
+ base,
11261
+ next: document,
11262
+ path: [],
11263
+ options: { formattingOptions: detectJsoncFormattingOptions({
11264
+ text: existingContent,
11265
+ root
11266
+ }) }
11267
+ });
11268
+ } catch {
11269
+ return whole;
11270
+ }
11271
+ }
11272
+ /**
10066
11273
  * Shallow merge: every top-level key in `patch` replaces the base key
10067
11274
  * wholesale; all other base keys are preserved. The policy for a feature that
10068
11275
  * owns a fixed set of top-level keys.
@@ -10085,7 +11292,7 @@ function mergeSharedConfigShallow({ base, patch }) {
10085
11292
  function mergeSharedConfigDeep({ base, patch }) {
10086
11293
  const result = { ...base };
10087
11294
  for (const [key, patchValue] of Object.entries(patch)) {
10088
- if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
11295
+ if (isPrototypePollutionKey(key)) continue;
10089
11296
  if (patchValue === void 0) {
10090
11297
  delete result[key];
10091
11298
  continue;
@@ -10667,7 +11874,10 @@ const SHARED_CONFIG_OWNERSHIP = {
10667
11874
  /**
10668
11875
  * Execute a feature's declared write to a gateway-managed shared file: parse
10669
11876
  * the existing content, merge the patch under the feature's declared policy,
10670
- * and serialize. Throws when the file or feature is undeclared, when a
11877
+ * and serialize it back over the existing content (see
11878
+ * {@link serializeSharedConfig}, which keeps a JSONC file's comments and
11879
+ * formatting outside the spans the merge actually changed). Throws when the
11880
+ * file or feature is undeclared, when a
10671
11881
  * `replace-owned-keys` patch strays outside its owned keys, or when the
10672
11882
  * feature's policy is `custom` (those calls go to the named policy function
10673
11883
  * instead).
@@ -10693,9 +11903,10 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
10693
11903
  patch
10694
11904
  });
10695
11905
  for (const [key, value] of Object.entries(patch)) if (value === void 0) delete document[key];
10696
- return stringifySharedConfig({
11906
+ return serializeSharedConfig({
10697
11907
  format: declaration.format,
10698
- document
11908
+ document,
11909
+ existingContent
10699
11910
  });
10700
11911
  }
10701
11912
  const merged = mergeSharedConfigDeep({
@@ -10703,9 +11914,10 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
10703
11914
  patch
10704
11915
  });
10705
11916
  for (const key of policy.replaceKeys ?? []) if (patch[key] !== void 0) merged[key] = sanitizeSharedConfigValue(patch[key]);
10706
- return stringifySharedConfig({
11917
+ return serializeSharedConfig({
10707
11918
  format: declaration.format,
10708
- document: merged
11919
+ document: merged,
11920
+ existingContent
10709
11921
  });
10710
11922
  }
10711
11923
  const READ_TOOL_NAME = "Read";
@@ -11193,7 +12405,7 @@ var ChecksProcessor = class extends FeatureProcessor {
11193
12405
  this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
11194
12406
  return [];
11195
12407
  }
11196
- const mdFiles = (await listDirectoryFiles(checksDir)).filter((file) => file.endsWith(".md"));
12408
+ const mdFiles = (await listDirectoryEntryNames(checksDir)).filter((file) => file.endsWith(".md"));
11197
12409
  if (mdFiles.length === 0) {
11198
12410
  this.logger.debug(`No markdown files found in rulesync checks directory: ${checksDir}`);
11199
12411
  return [];
@@ -16015,6 +17227,7 @@ const ZCODE_SKILLS_DIR_PATH = join(ZCODE_DIR, "skills");
16015
17227
  const ZCODE_CONFIG_FILE_NAME = "config.json";
16016
17228
  const ZCODE_GLOBAL_CONFIG_DIR_PATH = join(ZCODE_DIR, "cli");
16017
17229
  const ZCODE_MCP_SERVERS_KEY = "servers";
17230
+ const ZCODE_AGENTS_DIR_PATH = join(ZCODE_DIR, "agents");
16018
17231
  //#endregion
16019
17232
  //#region src/features/commands/zcode-command.ts
16020
17233
  /**
@@ -16977,6 +18190,52 @@ function compact(obj) {
16977
18190
  return result;
16978
18191
  }
16979
18192
  //#endregion
18193
+ //#region src/utils/quote-value.ts
18194
+ /**
18195
+ * How much of a value read off disk a diagnostic quotes.
18196
+ *
18197
+ * Enough to recognize which entry is meant, and no more. A warning names the
18198
+ * offending value so the reader can find it, but the values these warnings
18199
+ * quote come from files rulesync did not write — a tool's own settings, a
18200
+ * machine-local overrides file, a repository fetched from elsewhere — and they
18201
+ * no longer stop at a terminal: they travel into a `--json` document another
18202
+ * program parses and into an MCP result an agent reads as context. A command
18203
+ * line or a header is the shape most likely to carry a credential, and a long
18204
+ * value is the shape most likely to carry instructions aimed at the agent.
18205
+ */
18206
+ const MAX_QUOTED_VALUE_LENGTH = 60;
18207
+ /**
18208
+ * A short, quotable rendering of a value for a diagnostic.
18209
+ *
18210
+ * Serialized rather than interpolated, because an unquoted value is what lets a
18211
+ * crafted one read as a second line; stripped of the control characters
18212
+ * `JSON.stringify` leaves intact (it escapes C0 only, not the C1 range or the
18213
+ * bidirectional overrides); and truncated.
18214
+ */
18215
+ function quoteValueForWarning(value) {
18216
+ return truncateText({
18217
+ text: stripControlCharacters(serialize(value)),
18218
+ maxLength: MAX_QUOTED_VALUE_LENGTH,
18219
+ suffix: "…(truncated)"
18220
+ });
18221
+ }
18222
+ function serialize(value) {
18223
+ try {
18224
+ return JSON.stringify(value, stripStrings) ?? String(value);
18225
+ } catch {
18226
+ return `[unserializable ${typeof value}]`;
18227
+ }
18228
+ }
18229
+ /**
18230
+ * Strip the control characters out of every string before `JSON.stringify`
18231
+ * sees it, not only out of the document it produces: `JSON.stringify` escapes
18232
+ * a C0 character into the six literal characters `\u001b`, which no later pass
18233
+ * over the output can recognize as a control character again.
18234
+ */
18235
+ function stripStrings(_key, value) {
18236
+ return typeof value === "string" ? stripControlCharacters(value) : value;
18237
+ }
18238
+ //#endregion
16980
18239
  //#region src/features/hooks/tool-hooks-converter.ts
16981
18240
  function isToolMatcherEntry(x) {
16982
18241
  if (x === null || typeof x !== "object") return false;
@@ -17078,7 +18337,7 @@ function emitPassthroughFields({ def, hookType, eventName, fields, isValid, warn
17078
18337
  if (!isValid({
17079
18338
  value,
17080
18339
  canonical
17081
- })) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${JSON.stringify(value)} is not a value this tool can express as "${tool}".`);
18340
+ })) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${quoteValueForWarning(value)} is not a value this tool can express as "${tool}".`);
17082
18341
  }
17083
18342
  return Object.fromEntries(fields.filter(({ canonical, commandOnly }) => isFieldApplicable({
17084
18343
  commandOnly,
@@ -17173,7 +18432,7 @@ function describeScalarConstraint({ canonical, value }) {
17173
18432
  if (issue === void 0) return `it is not a value the canonical "${canonical}" field accepts.`;
17174
18433
  return `it does not satisfy the canonical "${canonical}" field: ${issue.message}.`;
17175
18434
  }
17176
- const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${JSON.stringify(value)}) while importing a hook: ${describeScalarConstraint({
18435
+ const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${quoteValueForWarning(value)}) while importing a hook: ${describeScalarConstraint({
17177
18436
  canonical,
17178
18437
  value
17179
18438
  })} Importing it would fail validation on the next run.`;
@@ -17200,7 +18459,7 @@ function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }
17200
18459
  if (first === void 0) continue;
17201
18460
  const firstStable = stableJson(first);
17202
18461
  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.`);
18462
+ 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
18463
  emitted[tool] = first;
17205
18464
  }
17206
18465
  return emitted;
@@ -17687,7 +18946,7 @@ function describeGroupSkipReason({ rawEntry, converterConfig }) {
17687
18946
  for (const { tool, valueType, subdividesGroup } of converterConfig.groupPassthroughFields ?? []) {
17688
18947
  const value = entry[tool];
17689
18948
  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.`;
18949
+ 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
18950
  }
17692
18951
  }
17693
18952
  /**
@@ -17704,7 +18963,7 @@ function describeHookSkipReason({ h, rawEntry, hookType, converterConfig }) {
17704
18963
  value,
17705
18964
  canonical: field
17706
18965
  })) continue;
17707
- return `Skipping a hook while importing: its "${field}" (${JSON.stringify(value)}) is unusable — ${describeScalarConstraint({
18966
+ return `Skipping a hook while importing: its "${field}" (${quoteValueForWarning(value)}) is unusable — ${describeScalarConstraint({
17708
18967
  canonical: field,
17709
18968
  value
17710
18969
  })} Keeping the hook without it would change what it does, so the whole hook is skipped.`;
@@ -18026,7 +19285,22 @@ async function readSettingsWithLocalOverlay({ outputRoot, relativeDirPath, baseF
18026
19285
  }
18027
19286
  /** Quotes a name read off disk, the way every other such name is logged. */
18028
19287
  function quoteKey(key) {
18029
- return JSON.stringify(stripControlCharacters(key));
19288
+ return quoteValueForWarning(key);
19289
+ }
19290
+ /**
19291
+ * How many keys the warning names before it stops counting.
19292
+ *
19293
+ * The keys come from a file rulesync did not write, and the warning now travels
19294
+ * into `--json` documents and MCP results as well as onto a console. A settings
19295
+ * file with hundreds of top-level keys is unusual but not impossible, and the
19296
+ * point of the sentence is to make the reader open the file — naming the first
19297
+ * few does that as well as naming all of them.
19298
+ */
19299
+ const MAX_LISTED_KEYS = 20;
19300
+ function listKeys(keys) {
19301
+ const named = keys.slice(0, MAX_LISTED_KEYS).map(quoteKey).join(", ");
19302
+ const rest = keys.length - MAX_LISTED_KEYS;
19303
+ return rest > 0 ? `${named} and ${rest} more` : named;
18030
19304
  }
18031
19305
  /**
18032
19306
  * Name the settings the machine-local file contributed, so nobody publishes one
@@ -18046,8 +19320,8 @@ function warnAboutLocalKeys({ localParsed, configPath, toolLabel, sensitiveKeys,
18046
19320
  const keys = Object.keys(localParsed);
18047
19321
  if (keys.length === 0) return;
18048
19322
  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}`);
19323
+ 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.`;
19324
+ 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
19325
  }
18052
19326
  //#endregion
18053
19327
  //#region src/utils/augmentcode-settings.ts
@@ -22742,6 +24016,9 @@ function withToolTargetPrefix({ logger, toolTarget }) {
22742
24016
  get silent() {
22743
24017
  return logger.silent;
22744
24018
  },
24019
+ get reportsWhileSilent() {
24020
+ return logger.reportsWhileSilent;
24021
+ },
22745
24022
  get jsonMode() {
22746
24023
  return logger.jsonMode;
22747
24024
  },
@@ -26042,7 +27319,7 @@ var CopilotMcp = class CopilotMcp extends ToolMcp {
26042
27319
  json;
26043
27320
  constructor(params) {
26044
27321
  super(params);
26045
- this.json = this.fileContent !== void 0 ? JSON.parse(this.fileContent) : {};
27322
+ this.json = this.fileContent !== void 0 ? parseJsonc(this.fileContent) : {};
26046
27323
  }
26047
27324
  getJson() {
26048
27325
  return this.json;
@@ -28265,7 +29542,7 @@ var KiloMcp = class KiloMcp extends ToolMcp {
28265
29542
  }, null, 2) });
28266
29543
  }
28267
29544
  validate() {
28268
- const json = JSON.parse(this.fileContent || "{}");
29545
+ const json = parseJsonc(this.fileContent || "{}");
28269
29546
  const result = KiloConfigSchema.safeParse(json);
28270
29547
  if (!result.success) return {
28271
29548
  success: false,
@@ -28856,7 +30133,7 @@ function convertFromMusecodeFormat(musecodeMcp) {
28856
30133
  if (key === "mode") {
28857
30134
  const mode = asMusecodeMode(value);
28858
30135
  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.`);
30136
+ 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
30137
  continue;
28861
30138
  }
28862
30139
  converted.musecodeMode = mode;
@@ -29341,7 +30618,7 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
29341
30618
  }, null, 2) });
29342
30619
  }
29343
30620
  validate() {
29344
- const json = JSON.parse(this.fileContent || "{}");
30621
+ const json = parseJsonc(this.fileContent || "{}");
29345
30622
  const result = OpencodeConfigSchema.safeParse(json);
29346
30623
  if (!result.success) return {
29347
30624
  success: false,
@@ -30060,7 +31337,7 @@ function pointerLabels(global) {
30060
31337
  async function warnAtDocumentedDefault({ existing, outputRoot, logger }) {
30061
31338
  const { pointer, configLabel, mcpLabel } = pointerLabels(true);
30062
31339
  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}".`));
31340
+ 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
31341
  }
30065
31342
  /**
30066
31343
  * Announce a pointer that was just written. Warned rather than noted in global
@@ -30117,10 +31394,10 @@ async function applyMcpConfigPointer({ existingMcp, global, hasLiveServers, outp
30117
31394
  return false;
30118
31395
  }
30119
31396
  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.`);
31397
+ 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
31398
  return false;
30122
31399
  }
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}".`);
31400
+ 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
31401
  return false;
30125
31402
  }
30126
31403
  /**
@@ -31740,11 +33017,10 @@ var ToolPermissions = class extends ToolFile {
31740
33017
  throw new Error("Please implement this method in the subclass.");
31741
33018
  }
31742
33019
  toRulesyncPermissionsDefault({ fileContent }) {
31743
- return new RulesyncPermissions({
33020
+ return RulesyncPermissions.fromImportedFileContent({
31744
33021
  outputRoot: this.outputRoot,
31745
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
31746
- relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
31747
- fileContent: withoutBlankPermissionPatterns({ fileContent })
33022
+ fileContent,
33023
+ sourcePath: this.getRelativePathFromCwd()
31748
33024
  });
31749
33025
  }
31750
33026
  static async fromFile(_params) {
@@ -32796,41 +34072,123 @@ function matchesGlobStep(step, character) {
32796
34072
  const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
32797
34073
  return step.negated ? !admitted : admitted;
32798
34074
  }
34075
+ /** Whether two single-character steps can both match one same character. */
34076
+ function stepsShareACharacter(left, right) {
34077
+ if (left.kind === "any" || right.kind === "any") return true;
34078
+ if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
34079
+ if (left.kind === "literal") return matchesGlobStep(right, left.character);
34080
+ if (right.kind === "literal") return matchesGlobStep(left, right.character);
34081
+ return true;
34082
+ }
34083
+ /** Whether every step from `index` on can match the empty string. */
34084
+ function isAllStars(steps, index) {
34085
+ for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
34086
+ return true;
34087
+ }
34088
+ /**
34089
+ * The most work one intersection walk will do, counted in cells times the cost
34090
+ * of one. Past it the two patterns are reported as intersecting without being
34091
+ * walked: the product of two lengths grows quadratically, and a pattern long
34092
+ * enough to reach this is pathological rather than a command anybody typed.
34093
+ * Answering `true` withholds an `allow`, which is the direction that fails
34094
+ * closed.
34095
+ */
34096
+ const MAX_INTERSECTION_CELLS = 1e6;
34097
+ /**
34098
+ * The most work a whole run of comparisons will do. A caller holding R
34099
+ * restrictions and A allow rules asks R x A times, and a per-pair cap alone
34100
+ * bounds none of that: a hundred restrictions against a hundred allow rules,
34101
+ * each pattern just under the per-pair cap, is ten thousand affordable walks
34102
+ * that together take minutes. The shared budget is spent down across the run
34103
+ * and, once it is gone, every remaining pair is reported as intersecting —
34104
+ * again the direction that withholds an `allow` rather than writing one.
34105
+ */
34106
+ const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
34107
+ /**
34108
+ * What a pair costs on top of the cells it walks: the call itself, sizing and
34109
+ * filling the two rows the table is held in, and collecting the answer.
34110
+ * Charging only cells would leave the *number* of pairs unbounded — a pair of
34111
+ * one-step patterns walks a single cell, so n short restrictions against n
34112
+ * short allow rules is n squared comparisons that never spend the budget down
34113
+ * however many of them there are. Charging a floor per pair puts pair count and
34114
+ * walk length on the same exhaustible resource.
34115
+ *
34116
+ * For the short patterns of an ordinary config the floor is the whole charge,
34117
+ * which lowers how many pairs a run compares from around a million to about
34118
+ * 150,000 — roughly 400 restrictions against 400 allow rules. A config past
34119
+ * that line withholds every allow it has not yet compared, the same fail-closed
34120
+ * answer exhaustion gives everywhere else.
34121
+ */
34122
+ const INTERSECTION_PAIR_COST = 64;
34123
+ /**
34124
+ * A budget for one caller's run of comparisons. Hand the same one to every
34125
+ * `parsedGlobsIntersect` call that belongs together — one adapter reading one
34126
+ * config — so the run as a whole stays bounded rather than only each pair in
34127
+ * it.
34128
+ */
34129
+ function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
34130
+ return { remaining };
34131
+ }
32799
34132
  /**
32800
- * Parse `glob` once and return a predicate that walks it, for a caller that
32801
- * tests the same glob against a whole list of names.
34133
+ * Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
34134
+ * the same pattern against a whole list parses it once and reuses the result.
32802
34135
  */
32803
- function compileGlob(glob) {
34136
+ function parseGlobPattern(glob) {
32804
34137
  const steps = parseGlob(glob);
32805
- return (value) => matchesParsedGlob(steps, value);
34138
+ return {
34139
+ steps,
34140
+ maxRanges: maxRangeCount(steps)
34141
+ };
32806
34142
  }
32807
- function matchesParsedGlob(steps, value) {
32808
- const characters = [...value];
32809
- let stepIndex = 0;
32810
- let characterIndex = 0;
32811
- let starStepIndex = -1;
32812
- let starCharacterIndex = 0;
32813
- while (characterIndex < characters.length) {
32814
- const step = steps[stepIndex];
32815
- if (step?.kind === "star") {
32816
- starStepIndex = stepIndex;
32817
- starCharacterIndex = characterIndex;
32818
- stepIndex += 1;
32819
- continue;
34143
+ /**
34144
+ * What one cell can cost, as a multiplier on the cell count. A literal met by a
34145
+ * `[a-z...]` class walks that class's ranges, so a single class carrying
34146
+ * thousands of them turns a walk that looks affordable by cell count alone into
34147
+ * a quadratic one — which is why the budget is spent on cells times this rather
34148
+ * than on cells.
34149
+ */
34150
+ function maxRangeCount(steps) {
34151
+ let most = 0;
34152
+ for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
34153
+ return most;
34154
+ }
34155
+ /**
34156
+ * `globsIntersect` for two globs already parsed, optionally spending a budget
34157
+ * shared with the rest of the caller's run — see `createIntersectionBudget`.
34158
+ * Once that budget is exhausted every further pair answers `true` without being
34159
+ * walked, so a caller reading the answer as a reason to restrict stays on the
34160
+ * safe side.
34161
+ */
34162
+ function parsedGlobsIntersect(left, right, budget) {
34163
+ const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
34164
+ const cellCost = 1 + left.maxRanges + right.maxRanges;
34165
+ const cost = rows.length * columns.length * cellCost;
34166
+ if (cost > MAX_INTERSECTION_CELLS) return true;
34167
+ if (budget !== void 0) {
34168
+ const charge = cost + INTERSECTION_PAIR_COST;
34169
+ if (charge > budget.remaining) {
34170
+ budget.remaining = 0;
34171
+ return true;
32820
34172
  }
32821
- if (step !== void 0 && matchesGlobStep(step, characters[characterIndex] ?? "")) {
32822
- stepIndex += 1;
32823
- characterIndex += 1;
32824
- continue;
34173
+ budget.remaining -= charge;
34174
+ }
34175
+ let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
34176
+ for (let i = rows.length - 1; i >= 0; i--) {
34177
+ const row = Array.from({ length: columns.length + 1 }, () => false);
34178
+ row[columns.length] = isAllStars(rows, i);
34179
+ for (let j = columns.length - 1; j >= 0; j--) {
34180
+ const rowStep = rows[i];
34181
+ const columnStep = columns[j];
34182
+ if (rowStep === void 0 || columnStep === void 0) continue;
34183
+ if (rowStep.kind === "star" || columnStep.kind === "star") {
34184
+ row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
34185
+ continue;
34186
+ }
34187
+ row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
32825
34188
  }
32826
- if (starStepIndex < 0) return false;
32827
- starCharacterIndex += 1;
32828
- stepIndex = starStepIndex + 1;
32829
- characterIndex = starCharacterIndex;
34189
+ next = row;
32830
34190
  }
32831
- let remaining = stepIndex;
32832
- while (steps[remaining]?.kind === "star") remaining += 1;
32833
- return remaining === steps.length;
34191
+ return next[0] ?? false;
32834
34192
  }
32835
34193
  //#endregion
32836
34194
  //#region src/features/permissions/augmentcode-permissions.ts
@@ -33293,6 +34651,216 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
33293
34651
  }
33294
34652
  return { permission };
33295
34653
  }
34654
+ /**
34655
+ * Collect the canonical rules that govern shell commands, for the adapters
34656
+ * whose tool models commands and nothing else.
34657
+ *
34658
+ * The `bash` category contributes every rule. The all-tools `*` category
34659
+ * contributes its **restricting** rules — `deny` and `ask` — because a rule
34660
+ * written there covers shell commands too, and dropping it inverts the
34661
+ * author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
34662
+ * an adapter that reads only `bash` auto-approves the very command the file
34663
+ * denies.
34664
+ *
34665
+ * Its `allow` rules are deliberately **not** contributed. A pattern under `*`
34666
+ * need not be a command at all — `secrets/**` under `*` denies a path — and
34667
+ * carrying it in the restricting direction only over-restricts, while carrying
34668
+ * it in the permissive direction would grant something the author never said
34669
+ * about commands. Both directions therefore fail closed.
34670
+ */
34671
+ function collectShellCommandRules(permission) {
34672
+ const rules = [];
34673
+ const foreignRestrictingCategories = [];
34674
+ const ignoredAllToolsAllowPatterns = [];
34675
+ for (const [category, categoryRules] of Object.entries(permission)) {
34676
+ if (category === "bash") {
34677
+ for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
34678
+ pattern,
34679
+ action,
34680
+ fromAllToolsCategory: false
34681
+ });
34682
+ continue;
34683
+ }
34684
+ if (category === "*") {
34685
+ for (const [pattern, action] of Object.entries(categoryRules)) {
34686
+ if (action === "allow") {
34687
+ ignoredAllToolsAllowPatterns.push(pattern);
34688
+ continue;
34689
+ }
34690
+ rules.push({
34691
+ pattern,
34692
+ action,
34693
+ fromAllToolsCategory: true
34694
+ });
34695
+ }
34696
+ continue;
34697
+ }
34698
+ if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
34699
+ }
34700
+ return {
34701
+ rules,
34702
+ foreignRestrictingCategories,
34703
+ ignoredAllToolsAllowPatterns
34704
+ };
34705
+ }
34706
+ /**
34707
+ * Build the test an adapter applies to an `allow` pattern before writing it:
34708
+ * which restrictions it cannot write name some of the same commands? The
34709
+ * answer is the list of those restrictions — empty when the `allow` may be
34710
+ * written — so a caller can report both the allow rules it withheld and the
34711
+ * restrictions that withheld nothing.
34712
+ *
34713
+ * Canonically the stricter rule wins **whatever its width** — rulesync collapses
34714
+ * colliding rules as `deny > ask > allow` — so the two patterns are compared by
34715
+ * asking whether any one command matches both. Width does not enter into it: an
34716
+ * `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
34717
+ * an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
34718
+ * on every `git ... --force` command even though neither pattern covers the
34719
+ * other's spelling. Comparing only identical spellings would let the most
34720
+ * ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
34721
+ *
34722
+ * Identical spellings are still compared as strings first, as a shortcut past
34723
+ * the walk for the commonest case.
34724
+ *
34725
+ * `normalizePattern` rewrites a pattern written in the tool's own language into
34726
+ * the widest glob it could stand for, for a tool whose patterns are not globs.
34727
+ * It reaches the `bash` rules and the `allow` rules, which is where such a
34728
+ * pattern is written; an all-tools `*` pattern is canonical — it is read by
34729
+ * every tool, so it is a glob already — and is compared as it stands. The
34730
+ * rewrite must only ever widen what a pattern covers, so an inexact reading
34731
+ * withholds an allow rather than writing one the config restricts — see
34732
+ * `warpCommandPatternToGlob`.
34733
+ */
34734
+ function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
34735
+ const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
34736
+ pattern,
34737
+ glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
34738
+ }));
34739
+ return (allowPattern) => {
34740
+ if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
34741
+ const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
34742
+ return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
34743
+ };
34744
+ }
34745
+ /**
34746
+ * Which of the given all-tools `*` restrictions look like they may not name a
34747
+ * command at all — the question a `deny` and an `ask` written there both raise.
34748
+ *
34749
+ * "Withheld no allow rule" alone does not answer it: a config with no `allow`
34750
+ * rules has nothing to withhold, and a pattern the author also wrote under
34751
+ * `bash` is a command on their own word. Both are excluded, so what remains is
34752
+ * a `*` pattern that had allow rules to overlap, overlapped none of them, and
34753
+ * is claimed as a command nowhere else — the shape `secrets/**` has.
34754
+ *
34755
+ * A `bash` restriction never belongs here: it names a command by construction,
34756
+ * so overlapping no allow rule says nothing is wrong with it.
34757
+ */
34758
+ function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
34759
+ if (!rules.some(({ action }) => action === "allow")) return [];
34760
+ const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
34761
+ return uniq(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
34762
+ }
34763
+ /**
34764
+ * Split shell-command rules into the allow and deny lists of a tool that models
34765
+ * commands with those two tiers and nothing else.
34766
+ *
34767
+ * `ask` has no list of its own — such a tool already prompts for whatever it
34768
+ * does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
34769
+ * still has to *withhold* the `allow` rules it covers, though: the canonical
34770
+ * order is `deny > ask > allow`, so auto-approving a command the file also asks
34771
+ * about would answer the prompt the author wanted.
34772
+ *
34773
+ * `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
34774
+ * from the all-tools `*` category. Warp's cannot: it matches commands with
34775
+ * regular expressions rather than globs, and writing any denylist **replaces**
34776
+ * Warp's built-in default one, so an inert `secrets/**` entry there would trade
34777
+ * the tool's own protection for a rule that matches no command. Where the deny
34778
+ * cannot be written it withholds the allow rules it covers instead, which
34779
+ * restricts in the same direction without touching the denylist.
34780
+ *
34781
+ * A `bash` deny withholds nothing: it names a command by construction, so the
34782
+ * denylist entry enforces it wherever the tool's deny-beats-allow order applies,
34783
+ * and a narrow deny keeps carving an exception out of a wider allow (`git *`
34784
+ * allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
34785
+ * even where it is written: a pattern under `*` need not name a command —
34786
+ * `secrets/**` there denies a path — so as a denylist entry it may match nothing
34787
+ * at all, and leaving an overlapping allow beside it would auto-approve the very
34788
+ * commands the author meant to stop. Over-restricting a `*` deny that *was* a
34789
+ * command pattern is reported; failing open would not be.
34790
+ *
34791
+ * `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
34792
+ * patterns are not globs.
34793
+ */
34794
+ function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
34795
+ const deny = [];
34796
+ const unwrittenDenyPatterns = [];
34797
+ const restrictions = [];
34798
+ const writtenAllToolsDenyPatterns = [];
34799
+ const allToolsAskPatterns = [];
34800
+ for (const rule of rules) {
34801
+ const { pattern, action, fromAllToolsCategory } = rule;
34802
+ if (action === "allow") continue;
34803
+ if (action !== "deny") {
34804
+ restrictions.push(rule);
34805
+ if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
34806
+ continue;
34807
+ }
34808
+ if (writesAllToolsDeny || !fromAllToolsCategory) {
34809
+ deny.push(pattern);
34810
+ if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
34811
+ } else unwrittenDenyPatterns.push(pattern);
34812
+ if (fromAllToolsCategory) restrictions.push(rule);
34813
+ }
34814
+ const budget = createIntersectionBudget();
34815
+ const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
34816
+ normalizePattern,
34817
+ budget
34818
+ });
34819
+ const allow = [];
34820
+ const shadowedAllowPatterns = [];
34821
+ const withholdingPatterns = /* @__PURE__ */ new Set();
34822
+ for (const { pattern, action } of rules) {
34823
+ if (action !== "allow") continue;
34824
+ const shadowing = shadowingRestrictions(pattern);
34825
+ if (shadowing.length > 0) {
34826
+ shadowedAllowPatterns.push(pattern);
34827
+ for (const restriction of shadowing) withholdingPatterns.add(restriction);
34828
+ continue;
34829
+ }
34830
+ allow.push(pattern);
34831
+ }
34832
+ return {
34833
+ allow,
34834
+ deny,
34835
+ shadowedAllowPatterns,
34836
+ unwrittenDenyPatterns,
34837
+ unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
34838
+ rules,
34839
+ allToolsPatterns: writtenAllToolsDenyPatterns,
34840
+ withholdingPatterns
34841
+ }),
34842
+ unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
34843
+ rules,
34844
+ allToolsPatterns: allToolsAskPatterns,
34845
+ withholdingPatterns
34846
+ }),
34847
+ intersectionBudgetExhausted: budget.remaining === 0
34848
+ };
34849
+ }
34850
+ /**
34851
+ * Report, for one command-only tool, every canonical rule its two lists could
34852
+ * not carry. Every command-only adapter shares this reporting, so a rule
34853
+ * dropped in one is worded the same way in all.
34854
+ */
34855
+ function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
34856
+ if (intersectionBudgetExhausted) warnWithFallback(logger, `${toolLabel} reached the limit on how much work one generation may spend comparing .rulesync/permissions.jsonc's allow rules against its deny and ask rules, so the allow rules left over were withheld rather than compared — the safe answer, but a wider one than the file asks for. Write fewer or shorter command patterns to have them all compared.`);
34857
+ for (const category of foreignRestrictingCategories) warnWithFallback(logger, `${toolLabel} only models shell-command permissions (${surfaceLabel}); '${category}' deny and ask rules cannot be represented and were skipped.`);
34858
+ if (unwrittenDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} did not write the all-tools '*' deny rule(s) for ${unwrittenDenyPatterns.join(", ")} into its denylist.${unwrittenDenyReason === void 0 ? "" : ` ${unwrittenDenyReason}`} They restrict only by withholding the allow rules they cover; write them under 'bash' to have them enforced as commands.`);
34859
+ if (unenforcedAllToolsDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} wrote the all-tools '*' deny rule(s) for ${unenforcedAllToolsDenyPatterns.join(", ")} into its denylist as they stand, but they withheld none of the allow rules beside them. A pattern written under '*' need not name a command — 'secrets/**' there denies a path — and a denylist entry that names none blocks nothing; write it under 'bash' too if it is a command pattern.`);
34860
+ if (unenforcedAllToolsAskPatterns.length > 0) warnWithFallback(logger, `${toolLabel} has no ask tier (${surfaceLabel}), so the all-tools '*' ask rule(s) for ${unenforcedAllToolsAskPatterns.join(", ")} restrict only by withholding the allow rules they cover — and they covered none. A pattern written under '*' need not name a command, so nothing observed says these ones do; write them under 'bash' if they are command patterns.`);
34861
+ if (ignoredAllToolsAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} reads the all-tools '*' category for its deny and ask rules only, so the allow rule(s) for ${ignoredAllToolsAllowPatterns.join(", ")} were skipped — a pattern written under '*' need not be a command. Write them under 'bash' to auto-approve them as commands.`);
34862
+ if (shadowedAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} was not given the allow rule(s) for ${shadowedAllowPatterns.join(", ")} because .rulesync/permissions.jsonc restricts the same commands elsewhere, and the stricter rule wins whatever its width.`);
34863
+ }
33296
34864
  //#endregion
33297
34865
  //#region src/features/permissions/claudecode-permissions.ts
33298
34866
  /**
@@ -34223,8 +35791,10 @@ function convertRulesyncToClaudePermissions({ config, logger }) {
34223
35791
  const ask = [];
34224
35792
  const deny = [];
34225
35793
  const actionByEntry = /* @__PURE__ */ new Map();
35794
+ const allToolsPatterns = [];
34226
35795
  for (const [category, rules] of Object.entries(config.permission)) {
34227
35796
  const claudeToolName = toClaudeToolName(category);
35797
+ if (category === "*") allToolsPatterns.push(...Object.keys(rules));
34228
35798
  for (const [pattern, action] of Object.entries(rules)) {
34229
35799
  const entry = buildClaudePermissionEntry(claudeToolName, pattern);
34230
35800
  const previous = actionByEntry.get(entry);
@@ -34241,6 +35811,7 @@ function convertRulesyncToClaudePermissions({ config, logger }) {
34241
35811
  }
34242
35812
  }
34243
35813
  }
35814
+ if (allToolsPatterns.length > 0) logger?.warn(`Claude Code permissions: a rule names one tool, and there is no name standing for every tool, so the all-tools '*' rule(s) for ${allToolsPatterns.join(", ")} were written as '*(pattern)' entries that Claude Code matches against no tool. Write them under the categories they are meant for (for example 'bash' or 'read') to have them enforced.`);
34244
35815
  return {
34245
35816
  allow,
34246
35817
  ask,
@@ -34285,34 +35856,64 @@ const ClineCommandPermissionsSchema = z.looseObject({
34285
35856
  });
34286
35857
  /**
34287
35858
  * Translate rulesync permission categories into Cline allow/deny command lists.
34288
- * Non-bash categories and `ask` rules are tracked separately so a single
34289
- * translation notice can be surfaced by the caller.
35859
+ * The `bash` category maps, and so do the restricting rules of the all-tools
35860
+ * `*` category a rule written there covers shell commands too. Other
35861
+ * categories and `ask` rules are tracked separately so a single translation
35862
+ * notice can be surfaced by the caller.
34290
35863
  */
34291
35864
  function translateClinePermissions(permission) {
34292
35865
  const allow = [];
34293
35866
  const deny = [];
34294
- const droppedCategories = [];
34295
35867
  const translatedAskPatterns = [];
34296
- for (const [category, rules] of Object.entries(permission)) {
34297
- if (category !== "bash") {
34298
- droppedCategories.push(category);
34299
- continue;
34300
- }
34301
- for (const [pattern, action] of Object.entries(rules)) {
34302
- if (action === "ask") {
34303
- translatedAskPatterns.push(pattern);
34304
- deny.push(pattern);
35868
+ const shadowedAllowPatterns = [];
35869
+ const droppedCategories = Object.keys(permission).filter((category) => category !== "bash" && category !== "*");
35870
+ const { rules, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permission);
35871
+ const budget = createIntersectionBudget();
35872
+ const shadowingRestrictions = createShadowingRestrictionsTest(rules.filter(({ fromAllToolsCategory }) => fromAllToolsCategory), { budget });
35873
+ const allToolsDenyPatterns = [];
35874
+ const allToolsAskPatterns = [];
35875
+ const withholdingPatterns = /* @__PURE__ */ new Set();
35876
+ for (const { pattern, action, fromAllToolsCategory } of rules) {
35877
+ if (action === "ask") {
35878
+ if (fromAllToolsCategory) {
35879
+ allToolsAskPatterns.push(pattern);
34305
35880
  continue;
34306
35881
  }
34307
- if (action === "allow") allow.push(pattern);
34308
- else if (action === "deny") deny.push(pattern);
35882
+ translatedAskPatterns.push(pattern);
35883
+ deny.push(pattern);
35884
+ continue;
35885
+ }
35886
+ if (action === "deny") {
35887
+ deny.push(pattern);
35888
+ if (fromAllToolsCategory) allToolsDenyPatterns.push(pattern);
35889
+ continue;
35890
+ }
35891
+ const shadowing = shadowingRestrictions(pattern);
35892
+ if (shadowing.length > 0) {
35893
+ shadowedAllowPatterns.push(pattern);
35894
+ for (const restriction of shadowing) withholdingPatterns.add(restriction);
35895
+ continue;
34309
35896
  }
35897
+ allow.push(pattern);
34310
35898
  }
34311
35899
  return {
34312
35900
  allow,
34313
35901
  deny,
34314
35902
  droppedCategories,
34315
- translatedAskPatterns
35903
+ translatedAskPatterns,
35904
+ shadowedAllowPatterns,
35905
+ unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
35906
+ rules,
35907
+ allToolsPatterns: allToolsDenyPatterns,
35908
+ withholdingPatterns
35909
+ }),
35910
+ unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
35911
+ rules,
35912
+ allToolsPatterns: allToolsAskPatterns,
35913
+ withholdingPatterns
35914
+ }),
35915
+ ignoredAllToolsAllowPatterns,
35916
+ intersectionBudgetExhausted: budget.remaining === 0
34316
35917
  };
34317
35918
  }
34318
35919
  /**
@@ -34321,11 +35922,16 @@ function translateClinePermissions(permission) {
34321
35922
  * project convention used by every other permissions translator, and
34322
35923
  * (b) the user still sees one prominent "WARNING" message describing the translation.
34323
35924
  */
34324
- function warnClineTranslationNotices({ droppedCategories, translatedAskPatterns, logger }) {
34325
- if (droppedCategories.length === 0 && translatedAskPatterns.length === 0) return;
35925
+ function warnClineTranslationNotices({ droppedCategories, translatedAskPatterns, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, ignoredAllToolsAllowPatterns, intersectionBudgetExhausted, logger }) {
35926
+ if (droppedCategories.length === 0 && translatedAskPatterns.length === 0 && shadowedAllowPatterns.length === 0 && unenforcedAllToolsDenyPatterns.length === 0 && unenforcedAllToolsAskPatterns.length === 0 && ignoredAllToolsAllowPatterns.length === 0 && !intersectionBudgetExhausted) return;
34326
35927
  const parts = [];
34327
35928
  if (droppedCategories.length > 0) parts.push(`non-bash categories [${droppedCategories.join(", ")}] (Cline only enforces shell commands; use the rulesync ignore feature for read/write restrictions)`);
34328
35929
  if (translatedAskPatterns.length > 0) parts.push(`'ask' rules for bash patterns [${translatedAskPatterns.join(", ")}] translated to 'deny' for fail-closed safety, since Cline lacks 'ask'`);
35930
+ if (shadowedAllowPatterns.length > 0) parts.push(`'allow' rules for [${shadowedAllowPatterns.join(", ")}] withheld because the all-tools '*' category restricts the same commands, and a pattern written there need not name a command Cline's own lists can act on. Cline's allowlist is a gate — once set, only the commands matching it run without approval — so withholding every entry leaves every command asking`);
35931
+ if (unenforcedAllToolsDenyPatterns.length > 0) parts.push(`'deny' rules for [${unenforcedAllToolsDenyPatterns.join(", ")}] under the all-tools '*' category written into the denylist as they stand, where they withheld none of the allow rules beside them — a pattern written there need not name a command, and a denylist entry that names none blocks nothing; write it under 'bash' too if it is a command pattern`);
35932
+ if (unenforcedAllToolsAskPatterns.length > 0) parts.push(`'ask' rules for [${unenforcedAllToolsAskPatterns.join(", ")}] under the all-tools '*' category left with nothing to do, since Cline has no ask tier and they withheld none of the allow rules beside them — a pattern written there need not name a command, so nothing observed says these ones do; write them under 'bash' if they are command patterns`);
35933
+ if (ignoredAllToolsAllowPatterns.length > 0) parts.push(`'allow' rules for [${ignoredAllToolsAllowPatterns.join(", ")}] under the all-tools '*' category skipped (only its deny and ask rules are read, since a pattern written there need not be a command); write them under 'bash' to auto-approve them`);
35934
+ if (intersectionBudgetExhausted) parts.push("the limit on how much work one generation may spend comparing allow rules against the all-tools '*' category's deny and ask rules was reached, so the allow rules left over were withheld rather than compared — the safe answer, but a wider one than .rulesync/permissions.jsonc asks for; write fewer or shorter command patterns to have them all compared");
34329
35935
  logger?.warn(`WARNING: Cline command permissions translation notice: ${parts.join("; ")}.`);
34330
35936
  }
34331
35937
  var ClinePermissions = class ClinePermissions extends ToolPermissions {
@@ -34373,10 +35979,15 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
34373
35979
  throw new Error(`Failed to parse existing Cline command-permissions at ${filePath}: ${formatError(error)}`, { cause: error });
34374
35980
  }
34375
35981
  const config = rulesyncPermissions.getJson();
34376
- const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(config.permission);
35982
+ const { allow, deny, droppedCategories, translatedAskPatterns, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, ignoredAllToolsAllowPatterns, intersectionBudgetExhausted } = translateClinePermissions(config.permission);
34377
35983
  warnClineTranslationNotices({
34378
35984
  droppedCategories,
34379
35985
  translatedAskPatterns,
35986
+ shadowedAllowPatterns,
35987
+ unenforcedAllToolsDenyPatterns,
35988
+ unenforcedAllToolsAskPatterns,
35989
+ ignoredAllToolsAllowPatterns,
35990
+ intersectionBudgetExhausted,
34380
35991
  logger
34381
35992
  });
34382
35993
  const dedupedAllow = uniq(allow.toSorted());
@@ -34384,7 +35995,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
34384
35995
  const mergedDeny = uniq([...existing.deny ?? [], ...dedupedDeny]).toSorted();
34385
35996
  const denySet = new Set(mergedDeny);
34386
35997
  const collisions = dedupedAllow.filter((p) => denySet.has(p));
34387
- if (collisions.length > 0) logger?.warn(`Cline command permissions: pattern(s) ${collisions.map((p) => `'${p}'`).join(", ")} appear in both 'allow' and 'deny'. Cline's evaluation order is not documented to guarantee deny-priority; the resulting behavior is undefined. Consider removing the duplicate rule from rulesync.`);
35998
+ if (collisions.length > 0) logger?.warn(`Cline command permissions: pattern(s) ${collisions.map((p) => `'${p}'`).join(", ")} appear in both 'allow' and 'deny'. Cline documents that deny rules always take precedence, so the 'allow' entry has no effect. Consider removing the duplicate rule.`);
34388
35999
  const next = {
34389
36000
  ...existing,
34390
36001
  allow: dedupedAllow,
@@ -35502,7 +37113,7 @@ function asCursorPermissionEntryArray(value, logger, fieldLabel) {
35502
37113
  if (!Array.isArray(value)) return [];
35503
37114
  const result = [];
35504
37115
  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)}.`);
37116
+ else logger?.warn(`Cursor CLI permissions${fieldLabel ? `.${fieldLabel}` : ""} contains a non-string entry; dropping ${quoteValueForWarning(item)}.`);
35506
37117
  return result;
35507
37118
  }
35508
37119
  /**
@@ -35803,7 +37414,9 @@ const TRAILING_ARGUMENT_WILDCARD_PATTERN = /:\*$/;
35803
37414
  * This surface is **global only** — dcode reads no project-level config file,
35804
37415
  * so there is nothing to write into a repository.
35805
37416
  *
35806
- * Only the canonical `bash` category maps, and only its `allow` rules:
37417
+ * Only `allow` rules map, and only from the canonical `bash` category — the
37418
+ * all-tools `*` category contributes its restricting rules instead, since a rule
37419
+ * written there covers shell commands too (see `collectShellCommandRules`):
35807
37420
  *
35808
37421
  * - A pattern is reduced to its executable token, because that is all dcode
35809
37422
  * matches on — `git *`, `git:*`, `git commit:*` and a bare `git` all become
@@ -35917,7 +37530,7 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
35917
37530
  const shell = isPlainObject$1(existingShell) ? { ...existingShell } : {};
35918
37531
  if (allowList.length > 0) shell[ALLOW_LIST_KEY] = allowList;
35919
37532
  else if (shell[ALLOW_LIST_KEY] !== void 0) {
35920
- warnWithFallback(logger, `deepagents-cli: no bash allow rule maps to an executable name, so the existing [${SHELL_TABLE_KEY}].${ALLOW_LIST_KEY} in ${filePath} was removed and those commands will be asked about again.`);
37533
+ warnWithFallback(logger, `deepagents-cli: no bash allow rule is left to auto-approve — none maps to an executable name, or every one of them is covered by a stricter rule — so the existing [${SHELL_TABLE_KEY}].${ALLOW_LIST_KEY} in ${filePath} was removed and those commands will be asked about again.`);
35921
37534
  delete shell[ALLOW_LIST_KEY];
35922
37535
  }
35923
37536
  if (Object.keys(shell).length > 0) settings[SHELL_TABLE_KEY] = shell;
@@ -35976,35 +37589,69 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
35976
37589
  }
35977
37590
  };
35978
37591
  /**
37592
+ * Split the restricting rules by what the generated allowlist does to them, and
37593
+ * name the entries that have to go.
37594
+ *
37595
+ * An allowlist entry auto-approves its executable however it is invoked, so an
37596
+ * `ask` or `deny` on any command that entry would run — a narrower pattern
37597
+ * (`npm publish`) beside an allowed `npm *`, or one naming no executable at all
37598
+ * (`*delete*` beside an allowed `kubectl`) — collides with it. dcode has no
37599
+ * denylist, so the collision cannot be settled there: keeping the allow would
37600
+ * auto-approve the very command the author wanted stopped. The colliding
37601
+ * entries are therefore withheld — canonically the stricter rule wins whatever
37602
+ * its width — which leaves those executables prompting, which is what an `ask`
37603
+ * asks for and the closest dcode can come to a `deny`.
37604
+ */
37605
+ function partitionRestrictingRules({ allowList, askPatterns, denyPatterns, willWrite }) {
37606
+ const approved = allowList.map((token) => ({
37607
+ token,
37608
+ globs: [parseGlobPattern(token), parseGlobPattern(`${token} *`)]
37609
+ }));
37610
+ const budget = createIntersectionBudget();
37611
+ const collidingTokens = (pattern) => {
37612
+ if (!willWrite) return [];
37613
+ if (budget.remaining === 0) return [...allowList];
37614
+ const restriction = parseGlobPattern(pattern.trim().replace(TRAILING_ARGUMENT_WILDCARD_PATTERN, "*").replaceAll(SHLEX_STRIPPED_PATTERN, ""));
37615
+ return approved.filter(({ globs }) => globs.some((glob) => parsedGlobsIntersect(restriction, glob, budget))).map(({ token }) => token);
37616
+ };
37617
+ const withheldTokens = /* @__PURE__ */ new Set();
37618
+ const collect = (pattern) => {
37619
+ const tokens = collidingTokens(pattern);
37620
+ for (const token of tokens) withheldTokens.add(token);
37621
+ return tokens.length > 0;
37622
+ };
37623
+ const shadowedAsk = uniq(askPatterns).filter(collect);
37624
+ const shadowedDeny = [];
37625
+ const unenforcedDeny = [];
37626
+ for (const pattern of uniq(denyPatterns)) (collect(pattern) ? shadowedDeny : unenforcedDeny).push(pattern);
37627
+ return {
37628
+ shadowedAsk,
37629
+ shadowedDeny,
37630
+ unenforcedDeny,
37631
+ withheldTokens,
37632
+ intersectionBudgetExhausted: budget.remaining === 0
37633
+ };
37634
+ }
37635
+ /**
35979
37636
  * Say what the reduction to executable names could not write. Split out from
35980
37637
  * `convertRulesyncToDeepagentsAllowList` because the rules it reports on
35981
37638
  * outnumber the ones it writes: every category dcode cannot express is a
35982
37639
  * sentence here.
35983
37640
  */
35984
- function warnAboutUnwrittenBashRules({ allowList, allowAll, requestedAllowAll, askPatterns, denyPatterns, widenedPatterns, unmatchablePatterns, sentinelPatterns, willWrite, logger }) {
35985
- const allowedTokens = new Set(allowList);
35986
- const collidesWithAllow = (pattern) => {
35987
- if (!willWrite) return false;
35988
- if (allowAll) return true;
35989
- const leading = leadingToken(pattern).replaceAll(SHLEX_STRIPPED_PATTERN, "");
35990
- if (GLOB_CHARACTERS_PATTERN.test(leading)) {
35991
- const matches = compileGlob(leading);
35992
- return allowList.some((token) => matches(token));
35993
- }
35994
- return allowedTokens.has(leading);
35995
- };
35996
- const shadowedAsk = askPatterns.filter(collidesWithAllow);
35997
- const shadowedDeny = [];
35998
- const unenforcedDeny = [];
35999
- for (const pattern of denyPatterns) (collidesWithAllow(pattern) ? shadowedDeny : unenforcedDeny).push(pattern);
36000
- if (unenforcedDeny.length > 0) warnWithFallback(logger, `deepagents-cli has no command denylist — a command it does not auto-approve is asked about, not blocked — so ${unenforcedDeny.length} bash deny rule(s) from .rulesync/permissions.jsonc were skipped and those commands remain runnable on approval.`);
36001
- const shadowReason = allowAll ? `allow_list = ["all"] auto-approves every command` : `the generated allow_list auto-approves commands they cover`;
36002
- if (shadowedDeny.length > 0) warnWithFallback(logger, `deepagents-cli matches only the executable name, so the deny rule(s) ${shadowedDeny.join(", ")} are not merely unenforced: ${shadowReason}, so those commands run without a prompt. Narrow or drop the allow rule that covers them.`);
36003
- if (shadowedAsk.length > 0) warnWithFallback(logger, `deepagents-cli matches only the executable name, so the ask rule(s) ${shadowedAsk.join(", ")} run without a prompt: ${shadowReason}. Narrow or drop the allow rule that covers them.`);
37641
+ function warnAboutUnwrittenBashRules({ allowAll, requestedAllowAll, askPatterns, denyPatterns, foreignRestrictingCategories, shadowedAsk, shadowedDeny, unenforcedDeny, intersectionBudgetExhausted, widenedPatterns, unmatchablePatterns, sentinelPatterns, willWrite, logger }) {
37642
+ if (unenforcedDeny.length > 0) warnWithFallback(logger, `deepagents-cli has no command denylist — a command it does not auto-approve is asked about, not blocked — so ${unenforcedDeny.length} command deny rule(s) from .rulesync/permissions.jsonc were skipped and those commands remain runnable on approval.`);
37643
+ if (shadowedDeny.length > 0) warnWithFallback(logger, intersectionBudgetExhausted ? `deepagents-cli withheld the allow_list entries beside the deny rule(s) ${shadowedDeny.join(", ")} without comparing them, the comparison limit above having been reached. Those executables are asked about instead.` : `deepagents-cli matches only the executable name, so the allow rule(s) covered by the deny rule(s) ${shadowedDeny.join(", ")} were withheld from allow_list — keeping them would auto-approve the very commands those rules deny. Those executables are asked about instead; narrow the deny rule if you meant them auto-approved.`);
37644
+ if (shadowedAsk.length > 0) warnWithFallback(logger, intersectionBudgetExhausted ? `deepagents-cli withheld the allow_list entries beside the ask rule(s) ${shadowedAsk.join(", ")} without comparing them, the comparison limit above having been reached. Those executables are asked about instead.` : `deepagents-cli matches only the executable name, so the allow rule(s) covered by the ask rule(s) ${shadowedAsk.join(", ")} were withheld from allow_list — keeping them would run those commands without the prompt the ask rule asks for. Narrow the ask rule if you meant them auto-approved.`);
36004
37645
  if (willWrite && widenedPatterns.length > 0) warnWithFallback(logger, `deepagents-cli matches only the executable name of a command, so ${widenedPatterns.join(", ")} were widened to their first token — every invocation of those executables is now auto-approved, not just the listed arguments.`);
36005
37646
  if (willWrite && unmatchablePatterns.length > 0) warnWithFallback(logger, `deepagents-cli compares an allow_list entry to the executable name exactly, so a glob in that name matches nothing, a name longer than ${MAX_EXECUTABLE_NAME_LENGTH} characters is longer than a command's own name can be, and a name holding a shell metacharacter, a quote or an escape is one dcode splits on, refuses outright, or reads differently than it is written — the pattern(s) ${unmatchablePatterns.join(", ")} were therefore skipped rather than written as a rule that cannot be relied on to fire.`);
36006
37647
  if (willWrite && sentinelPatterns.length > 0) warnWithFallback(logger, `deepagents-cli reads 'all' and 'recommended' in allow_list as sentinels rather than command names, so ${sentinelPatterns.join(", ")} were skipped. Use the '*' pattern to allow every command.`);
36007
- if (willWrite && requestedAllowAll && !allowAll) warnWithFallback(logger, `The bash '*' allow rule was not written as allow_list = ["all"] for deepagents-cli, because 'all' also turns off its dangerous-pattern check (command substitution, redirects, process substitution) and ${denyPatterns.length > 0 ? `your config denies commands` : `your config has deny rules for other tools`} — the two together would be weaker than dcode's own default. List the executables you want auto-approved instead.`);
37648
+ if (willWrite && requestedAllowAll && !allowAll) {
37649
+ let restrictionReason = `your config restricts other tools`;
37650
+ if (denyPatterns.length > 0) restrictionReason = `your config denies commands`;
37651
+ else if (askPatterns.length > 0) restrictionReason = `your config asks before running commands`;
37652
+ else if (foreignRestrictingCategories.length > 0) restrictionReason = `your config restricts '${foreignRestrictingCategories.join("', '")}'`;
37653
+ warnWithFallback(logger, `The bash '*' allow rule was not written as allow_list = ["all"] for deepagents-cli, because 'all' also turns off its dangerous-pattern check (command substitution, redirects, process substitution) and ${restrictionReason} — the two together would be weaker than dcode's own default. List the executables you want auto-approved instead.`);
37654
+ }
36008
37655
  if (willWrite && allowAll) warnWithFallback(logger, "The bash '*' allow rule became allow_list = [\"all\"] for deepagents-cli, which auto-approves every command and skips its dangerous-pattern check (command substitution, redirects, process substitution). List the executables you want instead if that is more than you meant.");
36009
37656
  }
36010
37657
  /**
@@ -36151,62 +37798,78 @@ function toExecutableToken(pattern) {
36151
37798
  }
36152
37799
  /**
36153
37800
  * Convert rulesync permissions config to dcode's `[shell].allow_list`. Only
36154
- * `bash` `allow` rules map; everything else is skipped, with a warning
37801
+ * `allow` rules map from the `bash` category, and never from the all-tools
37802
+ * `*` one, whose restricting rules still count against them (see
37803
+ * `collectShellCommandRules`). Everything else is skipped, with a warning
36155
37804
  * wherever the skip loses a restriction rather than a redundancy.
36156
37805
  */
36157
37806
  function convertRulesyncToDeepagentsAllowList({ config, willWrite, logger }) {
36158
37807
  const allowed = [];
36159
- const widenedPatterns = [];
37808
+ const widened = [];
36160
37809
  const unmatchablePatterns = [];
36161
37810
  const sentinelPatterns = [];
36162
37811
  const askPatterns = [];
36163
37812
  const denyPatterns = [];
36164
- let hasForeignDeny = false;
36165
37813
  let requestedAllowAll = false;
36166
- for (const [category, rules] of Object.entries(config.permission)) {
36167
- if (category !== "bash") {
36168
- if (Object.values(rules).some((action) => action === "deny")) {
36169
- hasForeignDeny = true;
36170
- warnWithFallback(logger, `deepagents-cli only models shell-command permissions ([shell].allow_list), so '${category}' deny rules cannot be represented and were skipped.`);
36171
- }
37814
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
37815
+ for (const { pattern, action } of rules) {
37816
+ if (action === "deny") {
37817
+ denyPatterns.push(pattern);
36172
37818
  continue;
36173
37819
  }
36174
- for (const [pattern, action] of Object.entries(rules)) {
36175
- if (action === "deny") {
36176
- denyPatterns.push(pattern);
36177
- continue;
36178
- }
36179
- if (action === "ask") {
36180
- askPatterns.push(pattern);
36181
- continue;
36182
- }
36183
- if (leadingToken(pattern) === "*" && meansAnyArguments(pattern)) {
36184
- requestedAllowAll = true;
36185
- continue;
36186
- }
36187
- const reduced = toExecutableToken(pattern);
36188
- if (!reduced) {
36189
- unmatchablePatterns.push(pattern);
36190
- continue;
36191
- }
36192
- if (reduced.token.toLowerCase() === ALLOW_ALL_SENTINEL || reduced.token.toLowerCase() === RECOMMENDED_SENTINEL) {
36193
- sentinelPatterns.push(pattern);
36194
- continue;
36195
- }
36196
- if (reduced.widened) widenedPatterns.push(pattern);
36197
- allowed.push(reduced.token);
37820
+ if (action === "ask") {
37821
+ askPatterns.push(pattern);
37822
+ continue;
37823
+ }
37824
+ if (leadingToken(pattern) === "*" && meansAnyArguments(pattern)) {
37825
+ requestedAllowAll = true;
37826
+ continue;
36198
37827
  }
37828
+ const reduced = toExecutableToken(pattern);
37829
+ if (!reduced) {
37830
+ unmatchablePatterns.push(pattern);
37831
+ continue;
37832
+ }
37833
+ if (reduced.token.toLowerCase() === ALLOW_ALL_SENTINEL || reduced.token.toLowerCase() === RECOMMENDED_SENTINEL) {
37834
+ sentinelPatterns.push(pattern);
37835
+ continue;
37836
+ }
37837
+ if (reduced.widened) widened.push({
37838
+ pattern,
37839
+ token: reduced.token
37840
+ });
37841
+ allowed.push(reduced.token);
36199
37842
  }
36200
- const hasDenyRule = hasForeignDeny || denyPatterns.length > 0;
36201
- const allowAll = requestedAllowAll && !hasDenyRule;
36202
- const allowList = allowAll ? [ALLOW_ALL_SENTINEL] : uniq(allowed.toSorted());
37843
+ const hasRestriction = foreignRestrictingCategories.length > 0 || denyPatterns.length > 0 || askPatterns.length > 0;
37844
+ const allowAll = requestedAllowAll && !hasRestriction;
37845
+ const candidates = uniq(allowed.toSorted());
37846
+ const { shadowedAsk, shadowedDeny, unenforcedDeny, withheldTokens, intersectionBudgetExhausted } = partitionRestrictingRules({
37847
+ allowList: candidates,
37848
+ askPatterns,
37849
+ denyPatterns,
37850
+ willWrite
37851
+ });
37852
+ const allowList = allowAll ? [ALLOW_ALL_SENTINEL] : candidates.filter((token) => !withheldTokens.has(token));
37853
+ warnAboutUnwrittenCommandRules({
37854
+ toolLabel: "deepagents-cli",
37855
+ surfaceLabel: "[shell].allow_list",
37856
+ foreignRestrictingCategories,
37857
+ shadowedAllowPatterns: [],
37858
+ ignoredAllToolsAllowPatterns,
37859
+ intersectionBudgetExhausted,
37860
+ logger
37861
+ });
36203
37862
  warnAboutUnwrittenBashRules({
36204
- allowList,
36205
37863
  allowAll,
36206
37864
  requestedAllowAll,
36207
37865
  askPatterns,
36208
37866
  denyPatterns,
36209
- widenedPatterns,
37867
+ foreignRestrictingCategories,
37868
+ shadowedAsk,
37869
+ shadowedDeny,
37870
+ unenforcedDeny,
37871
+ intersectionBudgetExhausted,
37872
+ widenedPatterns: uniq(widened.filter(({ token }) => !withheldTokens.has(token)).map(({ pattern }) => pattern)),
36210
37873
  unmatchablePatterns,
36211
37874
  sentinelPatterns,
36212
37875
  willWrite,
@@ -36506,10 +38169,17 @@ function convertDevinToRulesyncPermissions(params) {
36506
38169
  *
36507
38170
  * rulesync's canonical `permission.bash` patterns map directly: `allow` →
36508
38171
  * `commandAllowlist`, `deny` → `commandDenylist`. Factory Droid has no separate
36509
- * "ask" list (any command not in the allowlist already prompts), so `ask`
36510
- * rules are intentionally dropped. The allow/deny lists only model shell
36511
- * commands, so categories other than `bash` cannot be represented and are
36512
- * skipped (with a warning when they carry `deny` rules, to surface the gap).
38172
+ * "ask" list (any command not in the allowlist already prompts), so `ask` rules
38173
+ * write nothing they only withhold the allow rules they cover, since the
38174
+ * stricter rule wins whatever its width. The all-tools `*` category contributes
38175
+ * its restricting rules too, because a rule written there covers shell commands
38176
+ * as well. They withhold the allow rules they cover the way a `bash` `ask`
38177
+ * does, because a pattern written under `*` need not name a command at all: a
38178
+ * `deny` there is written to `commandDenylist` too, for the case where it *is*
38179
+ * one, but an entry naming no command enforces nothing by itself.
38180
+ * The allow/deny lists only model shell commands, so categories other
38181
+ * than `bash` and `*` cannot be represented and are skipped (with a warning
38182
+ * when they carry `deny` rules, to surface the gap).
36513
38183
  *
36514
38184
  * Factory Droid also has a stronger `commandBlocklist` tier — commands that can
36515
38185
  * never run, not even under full autonomy — plus other security controls
@@ -36626,24 +38296,28 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
36626
38296
  };
36627
38297
  /**
36628
38298
  * Convert rulesync permissions config to Factory Droid allow/deny command lists.
36629
- * Only the `bash` category maps; `ask` rules and non-`bash` categories are
36630
- * dropped (the latter with a warning when they carry `deny` rules).
38299
+ * The `bash` category maps, and so do the restricting rules of the all-tools
38300
+ * `*` category a `deny` written there covers shell commands too, and skipping
38301
+ * it would auto-approve a command the file blocks. Other categories are dropped
38302
+ * (with a warning when they carry `deny` rules).
36631
38303
  */
36632
38304
  function convertRulesyncToFactorydroidPermissions({ config, logger }) {
36633
- const allow = [];
36634
- const deny = [];
36635
- for (const [category, rules] of Object.entries(config.permission)) {
36636
- if (category !== "bash") {
36637
- if (Object.values(rules).some((action) => action === "deny") && logger) logger.warn(`Factory Droid only models shell-command permissions (commandAllowlist/commandDenylist); '${category}' deny rules cannot be represented and were skipped.`);
36638
- continue;
36639
- }
36640
- for (const [pattern, action] of Object.entries(rules)) switch (action) {
36641
- case "allow":
36642
- allow.push(pattern);
36643
- break;
36644
- case "deny": deny.push(pattern);
36645
- }
36646
- }
38305
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
38306
+ const { allow, deny, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
38307
+ rules,
38308
+ writesAllToolsDeny: true
38309
+ });
38310
+ warnAboutUnwrittenCommandRules({
38311
+ toolLabel: "Factory Droid",
38312
+ surfaceLabel: "commandAllowlist/commandDenylist",
38313
+ foreignRestrictingCategories,
38314
+ shadowedAllowPatterns,
38315
+ unenforcedAllToolsDenyPatterns,
38316
+ unenforcedAllToolsAskPatterns,
38317
+ ignoredAllToolsAllowPatterns,
38318
+ intersectionBudgetExhausted,
38319
+ logger
38320
+ });
36647
38321
  return {
36648
38322
  allow,
36649
38323
  deny
@@ -37356,7 +39030,10 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
37356
39030
  fileContent: this.getFileContent()
37357
39031
  });
37358
39032
  const rawProvenance = (isRecord$1(config.permissions) ? config.permissions : {}).rulesync;
37359
- const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(isRecord$1(rawProvenance) ? withoutBlankPermissionPatternsIn({ config: rawProvenance }) : rawProvenance);
39033
+ const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(isRecord$1(rawProvenance) ? withoutBlankPermissionKeysIn({
39034
+ config: rawProvenance,
39035
+ sourcePath: this.getRelativePathFromCwd()
39036
+ }) : rawProvenance);
37360
39037
  const provenance = parsedProvenance.success ? parsedProvenance.data : { permission: {} };
37361
39038
  const permission = clonePermissionBlock(provenance.permission);
37362
39039
  reconcileCommandAllowlist({
@@ -37384,14 +39061,13 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
37384
39061
  permission,
37385
39062
  ...Object.keys(hermes).length > 0 && { hermes }
37386
39063
  };
37387
- return new RulesyncPermissions({
39064
+ return RulesyncPermissions.fromImportedFileContent({
37388
39065
  outputRoot: getHermesagentRulesyncOutputRoot({
37389
39066
  nativeOutputRoot: this.outputRoot,
37390
39067
  global: this.global
37391
39068
  }),
37392
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
37393
- relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
37394
- fileContent: withoutBlankPermissionPatterns({ fileContent: JSON.stringify(imported, null, 2) })
39069
+ sourcePath: this.getRelativePathFromCwd(),
39070
+ fileContent: JSON.stringify(imported, null, 2)
37395
39071
  });
37396
39072
  }
37397
39073
  static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false }) {
@@ -38292,17 +39968,16 @@ var KimiCodePermissions = class KimiCodePermissions extends ToolPermissions {
38292
39968
  ...nativeRules.length > 0 && { rules: nativeRules },
38293
39969
  ...tools && { tools }
38294
39970
  };
38295
- return new RulesyncPermissions({
39971
+ return RulesyncPermissions.fromImportedFileContent({
38296
39972
  outputRoot: getKimiCodeRulesyncOutputRoot({
38297
39973
  nativeOutputRoot: this.outputRoot,
38298
39974
  global: this.global
38299
39975
  }),
38300
- relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
38301
- relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
38302
- fileContent: withoutBlankPermissionPatterns({ fileContent: JSON.stringify({
39976
+ sourcePath: this.getRelativePathFromCwd(),
39977
+ fileContent: JSON.stringify({
38303
39978
  permission,
38304
39979
  ...Object.keys(toolOverride).length > 0 && { "kimi-code": toolOverride }
38305
- }, null, 2) })
39980
+ }, null, 2)
38306
39981
  });
38307
39982
  }
38308
39983
  static forDeletion({ outputRoot = process.cwd() }) {
@@ -38693,9 +40368,10 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
38693
40368
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
38694
40369
  }
38695
40370
  const parsed = parse(fileContent ?? "{}");
40371
+ const record = isRecord$1(parsed) ? parsed : {};
38696
40372
  const nextJson = {
38697
- ...parsed,
38698
- permission: parsed.permission ?? {}
40373
+ ...record,
40374
+ permission: Object.hasOwn(record, "permission") ? record.permission ?? {} : {}
38699
40375
  };
38700
40376
  return new OpencodePermissions({
38701
40377
  outputRoot,
@@ -38764,7 +40440,7 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
38764
40440
  }
38765
40441
  validate() {
38766
40442
  try {
38767
- const json = JSON.parse(this.fileContent || "{}");
40443
+ const json = parseJsonc(this.fileContent || "{}");
38768
40444
  const result = OpencodePermissionsConfigSchema.safeParse(json);
38769
40445
  if (!result.success) return {
38770
40446
  success: false,
@@ -40876,6 +42552,28 @@ function pickSecurityPolicies(source, report) {
40876
42552
  * Ambiguous-width characters are counted as one column, which is what a
40877
42553
  * terminal running a Latin font does.
40878
42554
  *
42555
+ * Two of the ranges are here for a narrower reason: a name the skill prompt can
42556
+ * offer may not be counted narrower here than the prompt's own renderer counts
42557
+ * it, or a label that fits the budget wraps anyway and paints the second row
42558
+ * the budget exists to prevent. `@inquirer/core` measures with
42559
+ * `fast-string-width`, which takes the whole of `Script=Hangul` as wide and
42560
+ * every `Emoji_Modifier_Base` as an emoji. So the Hangul jamo are taken to
42561
+ * U+11FF rather than stopping at the leading consonants, and the modifier bases
42562
+ * are named beside the emoji: U+261D, U+26F9 and the two hands of U+270C–U+270D
42563
+ * are `Emoji` without being `Emoji_Presentation`, and were the only characters
42564
+ * outside Hangul this counted at one column while the renderer counted two.
42565
+ *
42566
+ * The rule is over the names that can reach the prompt, which is a smaller set
42567
+ * than the characters that exist. The renderer counts a tab at eight columns and
42568
+ * the Hangul fillers at two, where this counts one and none: a name carrying
42569
+ * either is refused outright by `hasDeceptiveHiddenCharacters` — the tab as a
42570
+ * control character, the fillers as characters that draw as nothing — and never
42571
+ * becomes a row to be measured. The two joiners are the invisible characters
42572
+ * that check lets through, so they are counted below rather than left to the
42573
+ * zero-width rule. Where this is used to lay out text of the tool's own rather
42574
+ * than to bound an untrusted name, the difference is a column of alignment and
42575
+ * not a forged row.
42576
+ *
40879
42577
  * The wide planes are taken whole rather than range by range — Tangut, Khitan
40880
42578
  * and Nushu together are U+17000–U+18DFF, and the kana supplements are
40881
42579
  * U+1AFF0–U+1B2FF — because a gap between two of them is exactly the character
@@ -40888,7 +42586,7 @@ function pickSecurityPolicies(source, report) {
40888
42586
  * is canonically the ordinary ideograph U+8C48, and a range that starts there
40889
42587
  * instead silently swallows thirty thousand code points that are not wide.
40890
42588
  */
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;
42589
+ 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
42590
  /**
40893
42591
  * U+FE0F VARIATION SELECTOR-16, which takes no width of its own but asks the
40894
42592
  * character before it to be drawn as an emoji — that is, in two columns rather
@@ -40902,6 +42600,27 @@ const COMBINING_MARK_PATTERN = /[\p{Mn}\p{Me}]/u;
40902
42600
  /** The characters that take no width at all, marks aside. */
40903
42601
  const ZERO_WIDTH_CHARACTERS_PATTERN = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
40904
42602
  /**
42603
+ * The zero-width joiner and its non-joining twin, which the renderer spends a
42604
+ * column on apiece.
42605
+ *
42606
+ * A terminal draws neither, and every other character that draws as nothing is
42607
+ * counted at nothing here. These two are the exception because they are the
42608
+ * only invisible characters `hasDeceptiveHiddenCharacters` lets through — a
42609
+ * Persian or Indic name spells a word with one, and an emoji is a chain of them
42610
+ * — so they are the only ones an attacker can put in a name that reaches the
42611
+ * prompt. `fast-string-width`, which is where the prompt's own wrapping is
42612
+ * decided, counts each of them as a column, and 40 of them in a name is 40
42613
+ * columns of budget this would otherwise hand over for free: enough for a name
42614
+ * measured at 39 columns to be drawn at 77 and wrap a forged row underneath
42615
+ * itself.
42616
+ *
42617
+ * The cost is that an emoji built from a chain is overstated by a column per
42618
+ * joiner, on top of the two columns per component it is already overstated by.
42619
+ * Overstating shortens a label that did not need it; understating lets one
42620
+ * wrap.
42621
+ */
42622
+ const RENDERER_COUNTED_JOINERS = /\u200c|\u200d/u;
42623
+ /**
40905
42624
  * How many marks a single character is allowed to carry for free.
40906
42625
  *
40907
42626
  * A written language stacks two or three at most — a Devanagari vowel sign and
@@ -40929,6 +42648,7 @@ function widthInContext(params) {
40929
42648
  const { character, precedingMarks } = params;
40930
42649
  if (character === EMOJI_PRESENTATION_SELECTOR) return 1;
40931
42650
  if (isCombiningMark(character)) return precedingMarks < FREE_MARKS_PER_CHARACTER ? 0 : 1;
42651
+ if (RENDERER_COUNTED_JOINERS.test(character)) return 1;
40932
42652
  if (ZERO_WIDTH_CHARACTERS_PATTERN.test(character)) return 0;
40933
42653
  return WIDE_CHARACTERS_PATTERN.test(character) ? 2 : 1;
40934
42654
  }
@@ -40951,6 +42671,8 @@ function displayWidthOf(text) {
40951
42671
  }
40952
42672
  return width;
40953
42673
  }
42674
+ /** The mark a cut string ends in. */
42675
+ const SHORTENING_ELLIPSIS = "…";
40954
42676
  /**
40955
42677
  * Cut `text` down to at most `budget` columns, marking the cut with an ellipsis.
40956
42678
  *
@@ -40976,7 +42698,7 @@ function shortenToWidth(params) {
40976
42698
  width += characterWidth;
40977
42699
  marks = isCombiningMark(character) ? marks + 1 : 0;
40978
42700
  }
40979
- return `${kept.join("")}…`;
42701
+ return `${kept.join("")}${SHORTENING_ELLIPSIS}`;
40980
42702
  }
40981
42703
  //#endregion
40982
42704
  //#region src/features/permissions/vibe-permissions.ts
@@ -42419,9 +44141,13 @@ function warpSettingsDir() {
42419
44141
  * allowlist, `deny` → denylist). Warp matches commands with regular
42420
44142
  * expressions, so patterns are emitted verbatim — author canonical `bash`
42421
44143
  * patterns as regexes when targeting Warp (mirrors the Zed permissions
42422
- * adapter). Warp has no per-command "ask" list, so `ask` rules are dropped; and
42423
- * the command lists only model shell commands, so non-`bash` categories are
42424
- * skipped (with a warning when they carry `deny` rules).
44144
+ * adapter). Warp has no per-command "ask" list, so `ask` rules write nothing
44145
+ * they only withhold the allow rules they cover, since the stricter rule wins
44146
+ * whatever its width. The all-tools `*` category is read for its restricting
44147
+ * rules as well, but those only withhold allows too: writing any denylist
44148
+ * **replaces** Warp's built-in default one, and a `*` pattern need not name a
44149
+ * command at all. Categories other than `bash` and `*` are skipped (with a
44150
+ * warning when they carry `deny` rules).
42425
44151
  *
42426
44152
  * Warp's `[agents.profiles]` table also exposes file-read/read-only autonomy
42427
44153
  * knobs that do not fit the canonical `allow | ask | deny` per-command model:
@@ -42594,25 +44320,267 @@ function mergeIntoDefaultExecutionProfile({ agents, mergedAllow, mergedDeny, exe
42594
44320
  if ((mergedDeny.length > 0 || hasOverrideKeys) && otherProfileIds.length > 0 && logger) logger.warn(`Warp command deny rules and execution_profile override keys were written to the 'default' execution profile only; they are not enforced while another profile (${otherProfileIds.join(", ")}) is active.`);
42595
44321
  }
42596
44322
  /**
42597
- * Convert rulesync permissions config to Warp command allow/deny regex lists.
42598
- * Only the `bash` category maps; `ask` rules and non-`bash` categories are
42599
- * dropped (the latter with a warning when they carry `deny` rules).
44323
+ * Read a `[...]` class starting at its `[` and return the index just past its
44324
+ * `]`, or `undefined` when it cannot be read that simply. Regex class rules
44325
+ * apply: a `]` in the first position is a member rather than the terminator and
44326
+ * a backslash escapes the character after it. A nested `[` gives up: Rust's
44327
+ * `regex` crate — the engine Warp matches with — reads `[a[b]c]` as one class
44328
+ * built by set operations, so stopping at the first `]` would leave `c]` behind
44329
+ * as text the glob then requires. Giving up widens the whole pattern instead,
44330
+ * which is the only safe direction here.
44331
+ */
44332
+ function skipRegexClass(body, start) {
44333
+ let index = start + 1;
44334
+ if (body.charAt(index) === "^") index += 1;
44335
+ if (body.charAt(index) === "]") index += 1;
44336
+ while (index < body.length) {
44337
+ const character = body.charAt(index);
44338
+ if (character === "\\") {
44339
+ index += 2;
44340
+ continue;
44341
+ }
44342
+ if (character === "[") return;
44343
+ if (character === "]") return index + 1;
44344
+ index += 1;
44345
+ }
44346
+ }
44347
+ /**
44348
+ * Read a `(...)` group starting at its `(` and return the index just past its
44349
+ * `)`, or `undefined` when it never closes. Groups nest, so the depth is
44350
+ * counted — but a class inside one is skipped whole, since a `)` written there
44351
+ * is a member rather than a closer.
42600
44352
  */
42601
- function convertRulesyncToWarpPermissions({ config, logger }) {
42602
- const allow = [];
42603
- const deny = [];
42604
- for (const [category, rules] of Object.entries(config.permission)) {
42605
- if (category !== "bash") {
42606
- if (Object.values(rules).some((action) => action === "deny") && logger) logger.warn(`Warp only models shell-command permissions (agent_mode_command_execution_allowlist/denylist); '${category}' deny rules cannot be represented and were skipped.`);
44353
+ function skipRegexGroup(body, start) {
44354
+ let depth = 0;
44355
+ let index = start;
44356
+ while (index < body.length) {
44357
+ const character = body.charAt(index);
44358
+ if (character === "\\") {
44359
+ index += 2;
42607
44360
  continue;
42608
44361
  }
42609
- for (const [pattern, action] of Object.entries(rules)) switch (action) {
42610
- case "allow":
42611
- allow.push(pattern);
42612
- break;
42613
- case "deny": deny.push(pattern);
44362
+ if (character === "[") {
44363
+ const next = skipRegexClass(body, index);
44364
+ if (next === void 0) return;
44365
+ index = next;
44366
+ continue;
44367
+ }
44368
+ if (character === "(") {
44369
+ depth += 1;
44370
+ index += 1;
44371
+ continue;
44372
+ }
44373
+ if (character === ")") {
44374
+ depth -= 1;
44375
+ index += 1;
44376
+ if (depth === 0) return index;
44377
+ continue;
42614
44378
  }
44379
+ index += 1;
42615
44380
  }
44381
+ }
44382
+ /**
44383
+ * Read a `{2,3}` quantifier starting at its `{`, or `undefined` when what
44384
+ * follows is not one. The shape is checked rather than scanned to the next `}`,
44385
+ * because a `{` that spells no repetition is a literal to the regex engine —
44386
+ * `{a|b}` is an alternation in braces, and reading it as a quantifier would skip
44387
+ * past the `|` that has to widen the whole pattern.
44388
+ */
44389
+ const QUANTIFIER = /\{\d+(,\d*)?\}/y;
44390
+ function skipQuantifier(body, start) {
44391
+ QUANTIFIER.lastIndex = start;
44392
+ return QUANTIFIER.exec(body) === null ? void 0 : QUANTIFIER.lastIndex;
44393
+ }
44394
+ /**
44395
+ * Read the bracketed construct that opens at `start`, whichever kind it is, and
44396
+ * return the index just past it — or `undefined` when it never closes.
44397
+ */
44398
+ function skipBracketedConstruct(body, start, open) {
44399
+ if (open === "[") return skipRegexClass(body, start);
44400
+ if (open === "(") return skipRegexGroup(body, start);
44401
+ return skipQuantifier(body, start);
44402
+ }
44403
+ /**
44404
+ * Read the tail of a `\\x` / `\\u` escape — the hex run of `\\x20`, or the
44405
+ * braced `\\x{263A}` form — and return the index just past it. Such an escape
44406
+ * spells one character across several, so consuming only its introducer would
44407
+ * leave the hex digits behind as literal atoms and *narrow* the glob, the one
44408
+ * direction the rewrite must never take. Over-consuming only widens, so an
44409
+ * unterminated brace swallows the rest of the pattern.
44410
+ */
44411
+ function skipHexEscapeTail(body, start) {
44412
+ if (body.charAt(start) === "{") {
44413
+ const closing = body.indexOf("}", start);
44414
+ return closing === -1 ? body.length : closing + 1;
44415
+ }
44416
+ let index = start;
44417
+ while (index < body.length && /^[0-9A-Fa-f]$/.test(body.charAt(index))) index += 1;
44418
+ return index;
44419
+ }
44420
+ /**
44421
+ * Read the tail of a `\\p` / `\\P` Unicode-class escape — the braced `\\p{Greek}`
44422
+ * form, or the one-letter `\\pL` shorthand — and return the index just past it.
44423
+ * Like a hex escape, it spells its class across more than one character, so
44424
+ * leaving the shorthand letter behind would narrow the glob.
44425
+ */
44426
+ function skipUnicodeClassTail(body, start) {
44427
+ if (body.charAt(start) === "{") {
44428
+ const closing = body.indexOf("}", start);
44429
+ return closing === -1 ? body.length : closing + 1;
44430
+ }
44431
+ return Math.min(start + 1, body.length);
44432
+ }
44433
+ /**
44434
+ * Read the escape that opens at `start` — the `\\` and whatever it spells — and
44435
+ * say where it ends and which atom it stands for.
44436
+ */
44437
+ function readEscape(body, start) {
44438
+ const escaped = body.charAt(start + 1);
44439
+ if (escaped === "x" || escaped === "u" || escaped === "U") return {
44440
+ next: skipHexEscapeTail(body, start + 2),
44441
+ atom: "*"
44442
+ };
44443
+ if (escaped === "p" || escaped === "P") return {
44444
+ next: skipUnicodeClassTail(body, start + 2),
44445
+ atom: "*"
44446
+ };
44447
+ return {
44448
+ next: start + 2,
44449
+ atom: escapedCharacterWidens(escaped) ? "*" : escaped
44450
+ };
44451
+ }
44452
+ /**
44453
+ * Whether an escaped character has to widen to `*` rather than stand for
44454
+ * itself: a letter or a digit spells a class (`\s`, `\d`, `\w`), a glob
44455
+ * metacharacter would be read as a wildcard or a class by the comparison
44456
+ * instead of as the literal the escape asked for, and an empty string is a
44457
+ * trailing backslash spelling nothing at all.
44458
+ */
44459
+ function escapedCharacterWidens(escaped) {
44460
+ return escaped === "" || /^[A-Za-z0-9*?[\]]$/.test(escaped);
44461
+ }
44462
+ /**
44463
+ * Read one `[...]`, `(...)` or `{...}` construct starting at `start`, saying
44464
+ * where it ends and whether it repeats the atom in front of it (a quantifier)
44465
+ * or is an atom of its own (a class or a group) — or that the whole pattern has
44466
+ * to widen, which is the only safe reading of a construct that never closes and
44467
+ * of one that sets flags for everything after it.
44468
+ */
44469
+ function readBracketedConstruct(body, start, open) {
44470
+ const next = skipBracketedConstruct(body, start, open);
44471
+ if (next === void 0) return "widens-pattern";
44472
+ if (open === "(" && INLINE_FLAG_GROUP_PATTERN.test(body.slice(start, next))) return "widens-pattern";
44473
+ return {
44474
+ next,
44475
+ widensLastAtom: open === "{"
44476
+ };
44477
+ }
44478
+ /**
44479
+ * A group that only sets flags — `(?i)`, `(?im)`, `(?-i)` — as opposed to one
44480
+ * that scopes them to its own body (`(?i:...)`, which widens to `*` like any
44481
+ * other group).
44482
+ */
44483
+ const INLINE_FLAG_GROUP_PATTERN = /^\(\?[A-Za-z]*-?[A-Za-z]*\)$/;
44484
+ /**
44485
+ * Approximate a Warp command pattern as a glob, so restrictions and allow rules
44486
+ * can be compared by `createShadowingRestrictionsTest`.
44487
+ *
44488
+ * Warp matches commands with regular expressions, and a glob reader would
44489
+ * misread the ordinary spellings: `.*` — Warp's catch-all — is a literal dot
44490
+ * followed by a wildcard, `[rf]` is a character class in one language and plain
44491
+ * text in the other, and an unanchored regex covers every command that merely
44492
+ * contains it. The rewrite therefore only ever widens what a pattern covers, so
44493
+ * an inexact reading withholds an allow rather than writing one the config
44494
+ * restricts.
44495
+ *
44496
+ * Widening means a construct is replaced whole rather than character by
44497
+ * character, because the characters inside it are not literals of the command:
44498
+ * a class (`[rf]`), a group (`(sudo )?`), a character escape (`\s`) and a
44499
+ * missing `^`/`$` anchor all become `*`, and a quantifier (`?`, `*`, `+`,
44500
+ * `{2}`) widens the atom in front of it — `git commits?` covers `git commit`,
44501
+ * so its glob has to as well. Both sides of a comparison are widened, so a
44502
+ * pattern that is really a glob still compares sensibly.
44503
+ *
44504
+ * A pattern the walk cannot read as one sequence widens to `*` whole: a
44505
+ * top-level `|` is two patterns rather than one, and a class, group or
44506
+ * quantifier that never closes leaves the rest of the pattern unreadable —
44507
+ * guessing at either could only narrow the result, which is the one direction
44508
+ * this rewrite must never take. An alternation *inside* a group needs no such
44509
+ * treatment: the group it sits in already widens to `*`.
44510
+ */
44511
+ function warpCommandPatternToGlob(pattern) {
44512
+ const anchoredStart = pattern.startsWith("^");
44513
+ const anchoredEnd = pattern.endsWith("$") && !pattern.endsWith("\\$");
44514
+ const body = pattern.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : void 0);
44515
+ const atoms = [];
44516
+ const widenLastAtom = () => {
44517
+ if (atoms.length === 0) {
44518
+ atoms.push("*");
44519
+ return;
44520
+ }
44521
+ atoms[atoms.length - 1] = "*";
44522
+ };
44523
+ let index = 0;
44524
+ while (index < body.length) {
44525
+ const character = body.charAt(index);
44526
+ if (character === "|") return "*";
44527
+ if (character === "\\") {
44528
+ const escape = readEscape(body, index);
44529
+ index = escape.next;
44530
+ atoms.push(escape.atom);
44531
+ continue;
44532
+ }
44533
+ if (character === "[" || character === "(" || character === "{") {
44534
+ const read = readBracketedConstruct(body, index, character);
44535
+ if (read === "widens-pattern") return "*";
44536
+ index = read.next;
44537
+ if (read.widensLastAtom) widenLastAtom();
44538
+ else atoms.push("*");
44539
+ continue;
44540
+ }
44541
+ if (character === "*" || character === "+" || character === "?") {
44542
+ index += 1;
44543
+ widenLastAtom();
44544
+ continue;
44545
+ }
44546
+ const codePoint = body.codePointAt(index);
44547
+ const atom = codePoint === void 0 ? character : String.fromCodePoint(codePoint);
44548
+ index += atom.length;
44549
+ atoms.push(atom === "." || ")]}^$".includes(atom) ? "*" : atom);
44550
+ }
44551
+ const glob = atoms.join("");
44552
+ return `${anchoredStart ? "" : "*"}${glob}${anchoredEnd ? "" : "*"}`;
44553
+ }
44554
+ /**
44555
+ * Convert rulesync permissions config to Warp command allow/deny regex lists.
44556
+ * The `bash` category maps to both lists. The all-tools `*` category's
44557
+ * restricting rules are read too — a rule written there covers shell commands
44558
+ * as well, and ignoring it would auto-approve a command the file blocks — but
44559
+ * they only *withhold* the allow rules they cover: Warp's denylist is a regex
44560
+ * list that replaces the tool's built-in default one, so writing a pattern
44561
+ * there that may not even name a command would cost more protection than it
44562
+ * adds. Other categories are dropped (with a warning when they carry `deny`
44563
+ * rules).
44564
+ */
44565
+ function convertRulesyncToWarpPermissions({ config, logger }) {
44566
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
44567
+ const { allow, deny, shadowedAllowPatterns, unwrittenDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
44568
+ rules,
44569
+ writesAllToolsDeny: false,
44570
+ normalizePattern: warpCommandPatternToGlob
44571
+ });
44572
+ warnAboutUnwrittenCommandRules({
44573
+ toolLabel: "Warp",
44574
+ surfaceLabel: "agent_mode_command_execution_allowlist/denylist",
44575
+ foreignRestrictingCategories,
44576
+ shadowedAllowPatterns,
44577
+ unwrittenDenyPatterns,
44578
+ unwrittenDenyReason: "Writing any denylist replaces Warp's built-in default one, and a pattern written under '*' need not be a command at all.",
44579
+ unenforcedAllToolsAskPatterns,
44580
+ ignoredAllToolsAllowPatterns,
44581
+ intersectionBudgetExhausted,
44582
+ logger
44583
+ });
42616
44584
  return {
42617
44585
  allow,
42618
44586
  deny
@@ -44764,6 +46732,83 @@ const NESTED_SCAN_EXCLUDED_ROOT_DIRS = [
44764
46732
  ];
44765
46733
  //#endregion
44766
46734
  //#region src/features/skills/claudecode-skill.ts
46735
+ /**
46736
+ * The `.claude/skills` tail every scanned root is expected to end with, written
46737
+ * posix-separated so it can be compared against a resolved relative path, and
46738
+ * split into its segments so what sits above it can be taken apart.
46739
+ *
46740
+ * Split here rather than through the shared helper: this is evaluated as the
46741
+ * module loads, before a test that mocks the file utilities can supply one.
46742
+ */
46743
+ const CLAUDECODE_SKILLS_DIR_SEGMENTS = CLAUDECODE_SKILLS_DIR_PATH.split(sep);
46744
+ const CLAUDECODE_SKILLS_DIR_POSIX_PATH = CLAUDECODE_SKILLS_DIR_SEGMENTS.join("/");
46745
+ /**
46746
+ * The segment that puts `relativeDirPath` inside a tree the nested scan
46747
+ * excludes, or `undefined` when none does. These are the same three rules the
46748
+ * scan states as glob `ignore` patterns below -- dependency trees at any depth,
46749
+ * build and vendoring directories at the project root, hidden directories other
46750
+ * than the `.claude` being matched -- and the two have to be kept in step.
46751
+ *
46752
+ * Saying them twice is what a second pass costs. globby matches its patterns
46753
+ * against the path it reports, before the `..` a rewritten directory name
46754
+ * carries is folded away and before a link in the path is resolved. A root
46755
+ * reported at `x/../node_modules/.claude/skills` matches none of the patterns
46756
+ * and then leads to the dependency tree they name, so the decision has to be
46757
+ * taken again on the path that is really read.
46758
+ *
46759
+ * The segments passed are the ones above the `.claude/skills` tail, taken from
46760
+ * the resolved path: a directory name may hold a backslash -- the whole reason a
46761
+ * path can arrive here misspelled -- so the split that produced them has to be on
46762
+ * `/`, the one separator no name can contain. The tail itself is the part the
46763
+ * glob matched and is not judged; `.claude` is hidden by definition and every
46764
+ * root the scan reports ends with it.
46765
+ */
46766
+ function excludedNestedScanSegment(segments) {
46767
+ return segments.find((segment, index) => NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.includes(segment) || index === 0 && NESTED_SCAN_EXCLUDED_ROOT_DIRS.includes(segment) || isHiddenPathSegment(segment));
46768
+ }
46769
+ /**
46770
+ * Whether a nested skills directory the scan reported can be used as an import
46771
+ * root: either the reason it cannot, or the path it resolves to relative to the
46772
+ * project, which the caller uses to tell two spellings of one root apart.
46773
+ *
46774
+ * A recursive glob cannot be swapped for a walk the way a flat one can, so the
46775
+ * path it hands back is checked instead. globby reads a backslash as a path
46776
+ * separator and rewrites it, so a root below a directory really named
46777
+ * `back\\slash` is reported at `back/slash`. Where that leads decides what to
46778
+ * do with it, and the spelling alone does not say: `back/slash` usually answers
46779
+ * to nothing, but `x\\..\\..\\outside` is reported at `x/../../outside`, which
46780
+ * climbs out of the project through the real sibling `x/`, and `a\\b` at `a/b`,
46781
+ * which may be a symbolic link out of the project that the scan — it passes
46782
+ * `followSymbolicLinks: false` — never meant to reach. Both are refused by
46783
+ * resolving the path rather than reading it.
46784
+ *
46785
+ * What is deliberately not refused is a rewritten path that stays inside the
46786
+ * project, such as `x\\..\\y` reported at `x/../y`. It names a real directory
46787
+ * `y`, and the scan reports that directory under this spelling *instead of* its
46788
+ * own, so refusing it would lose `y`'s skills rather than protect anything. The
46789
+ * skills under the directory that was really named are unreachable either way:
46790
+ * no path the scan can report leads back to a name holding a backslash.
46791
+ *
46792
+ * That last shape is the one case the scan cannot warn about. `a\\b` reported
46793
+ * at `a/b`, where `a/b` is itself a real directory, is indistinguishable from
46794
+ * the ordinary root `a/b` -- both are spelled the same and both are there -- so
46795
+ * the skills under `a\\b` are dropped without a word. Nothing in the path says
46796
+ * a second directory was ever involved.
46797
+ */
46798
+ async function checkNestedSkillsRoot({ outputRoot, dirPath }) {
46799
+ 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." };
46800
+ const realRelativeDirPath = await resolvedRelativePath({
46801
+ rootPath: outputRoot,
46802
+ targetPath: dirPath
46803
+ });
46804
+ if (posixRelativePathEscapesRoot(realRelativeDirPath)) return { reason: "it resolves outside the project." };
46805
+ const segments = realRelativeDirPath.split("/");
46806
+ const aboveTailSegments = segments.slice(0, -CLAUDECODE_SKILLS_DIR_SEGMENTS.length);
46807
+ if (segments.slice(-CLAUDECODE_SKILLS_DIR_SEGMENTS.length).join("/") !== CLAUDECODE_SKILLS_DIR_POSIX_PATH) return { reason: `it resolves to ${realRelativeDirPath === "" ? "the project root" : JSON.stringify(stripControlCharacters(realRelativeDirPath))}, which is not a ${CLAUDECODE_SKILLS_DIR_POSIX_PATH} directory.` };
46808
+ const excludedSegment = excludedNestedScanSegment(aboveTailSegments);
46809
+ if (excludedSegment !== void 0) return { reason: `it resolves inside ${JSON.stringify(stripControlCharacters(excludedSegment))}, which the nested scan excludes.` };
46810
+ return { realRelativeDirPath };
46811
+ }
44767
46812
  const ClaudecodeSkillFrontmatterSchema = z.looseObject({
44768
46813
  name: z.string(),
44769
46814
  description: z.string(),
@@ -45012,10 +47057,10 @@ var ClaudecodeSkill = class extends ToolSkill {
45012
47057
  *
45013
47058
  * @see https://code.claude.com/docs/en/skills
45014
47059
  */
45015
- static async getConfiguredImportRoots({ outputRoot, global = false }) {
47060
+ static async getConfiguredImportRoots({ outputRoot, global = false, logger }) {
45016
47061
  if (global) return [];
45017
47062
  const root = toPosixPath(outputRoot);
45018
- return filterOutPathsInGitIgnoredDirectories({
47063
+ const filteredDirPaths = filterOutPathsInGitIgnoredDirectories({
45019
47064
  rootDir: outputRoot,
45020
47065
  filePaths: await findFilesByGlobs([`${root}/*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`], {
45021
47066
  type: "dir",
@@ -45026,10 +47071,27 @@ var ClaudecodeSkill = class extends ToolSkill {
45026
47071
  ...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
45027
47072
  ]
45028
47073
  })
45029
- }).toSorted().map((dirPath) => ({
45030
- outputRoot,
45031
- relativeDirPath: relative(outputRoot, dirPath)
45032
- }));
47074
+ }).toSorted();
47075
+ const roots = [];
47076
+ const seenRealRelativeDirPaths = /* @__PURE__ */ new Set([CLAUDECODE_SKILLS_DIR_POSIX_PATH]);
47077
+ for (const dirPath of filteredDirPaths) {
47078
+ const scannedDirPath = resolve(dirPath);
47079
+ const check = await checkNestedSkillsRoot({
47080
+ outputRoot,
47081
+ dirPath: scannedDirPath
47082
+ });
47083
+ if ("reason" in check) {
47084
+ logger?.warn(`Skipping the nested Claude Code skills directory ${JSON.stringify(stripControlCharacters(scannedDirPath))}: ${check.reason} Its skills are not imported.`);
47085
+ continue;
47086
+ }
47087
+ if (seenRealRelativeDirPaths.has(check.realRelativeDirPath)) continue;
47088
+ seenRealRelativeDirPaths.add(check.realRelativeDirPath);
47089
+ roots.push({
47090
+ outputRoot,
47091
+ relativeDirPath: relative(outputRoot, scannedDirPath)
47092
+ });
47093
+ }
47094
+ return roots;
45033
47095
  }
45034
47096
  getFrontmatter() {
45035
47097
  return ClaudecodeSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
@@ -49916,7 +51978,8 @@ var SkillsProcessor = class extends DirFeatureProcessor {
49916
51978
  const paths = factory.class.getSettablePaths({ global: this.global });
49917
51979
  const configuredRoots = factory.class.getConfiguredImportRoots ? await factory.class.getConfiguredImportRoots({
49918
51980
  outputRoot: this.outputRoot,
49919
- global: this.global
51981
+ global: this.global,
51982
+ logger: this.logger
49920
51983
  }) : [];
49921
51984
  const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
49922
51985
  const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
@@ -54632,6 +56695,152 @@ var VibeSubagent = class VibeSubagent extends ToolSubagent {
54632
56695
  }
54633
56696
  };
54634
56697
  //#endregion
56698
+ //#region src/features/subagents/zcode-subagent.ts
56699
+ const ZcodeSubagentFrontmatterSchema = z.looseObject({
56700
+ name: z.string(),
56701
+ description: z.optional(z.string()),
56702
+ model: z.optional(z.string()),
56703
+ thoughtLevel: z.optional(z.string()),
56704
+ color: z.optional(z.string()),
56705
+ tools: z.optional(z.array(z.string())),
56706
+ disallowedTools: z.optional(z.array(z.string())),
56707
+ maxTurns: z.optional(z.number().check(z.int(), z.positive())),
56708
+ injectAgentsMd: z.optional(z.boolean()),
56709
+ mcpServers: z.optional(z.array(z.string()))
56710
+ });
56711
+ /**
56712
+ * ZCode subagents.
56713
+ *
56714
+ * Each subagent is one Markdown file with YAML frontmatter, named after the
56715
+ * agent, under `~/.zcode/agents/`.
56716
+ *
56717
+ * Global scope only. The current Beta "manages global / user-level subagents
56718
+ * stored under `~/.zcode/agents/`", and creating or editing workspace /
56719
+ * project-level subagents "is not available yet" — so this adapter is
56720
+ * registered with `supportsProject: false` and never writes into a project's
56721
+ * own `.zcode/`. The relative path is nonetheless spelled against
56722
+ * {@link ZCODE_AGENTS_DIR_PATH} so the workspace scope needs nothing more than
56723
+ * flipping that flag if ZCode ships it.
56724
+ *
56725
+ * @see https://zcode.z.ai/en/docs/subagents
56726
+ */
56727
+ var ZcodeSubagent = class ZcodeSubagent extends ToolSubagent {
56728
+ frontmatter;
56729
+ body;
56730
+ constructor({ frontmatter, body, fileContent, ...rest }) {
56731
+ if (rest.validate !== false) {
56732
+ const result = ZcodeSubagentFrontmatterSchema.safeParse(frontmatter);
56733
+ if (!result.success) throw new Error(`Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
56734
+ }
56735
+ super({
56736
+ ...rest,
56737
+ fileContent: fileContent ?? stringifyFrontmatter(body, frontmatter)
56738
+ });
56739
+ this.frontmatter = frontmatter;
56740
+ this.body = body;
56741
+ }
56742
+ static getSettablePaths(_options = {}) {
56743
+ return { relativeDirPath: ZCODE_AGENTS_DIR_PATH };
56744
+ }
56745
+ getFrontmatter() {
56746
+ return this.frontmatter;
56747
+ }
56748
+ getBody() {
56749
+ return this.body;
56750
+ }
56751
+ toRulesyncSubagent() {
56752
+ const { name, description, ...rest } = this.frontmatter;
56753
+ return new RulesyncSubagent({
56754
+ outputRoot: ".",
56755
+ frontmatter: {
56756
+ targets: ["*"],
56757
+ name,
56758
+ description,
56759
+ ...Object.keys(rest).length > 0 && { zcode: rest }
56760
+ },
56761
+ body: this.body,
56762
+ relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
56763
+ relativeFilePath: this.getRelativeFilePath(),
56764
+ validate: true
56765
+ });
56766
+ }
56767
+ static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
56768
+ const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
56769
+ const zcodeSection = rulesyncFrontmatter.zcode ?? {};
56770
+ const zcodeFrontmatter = {
56771
+ name: rulesyncFrontmatter.name,
56772
+ description: rulesyncFrontmatter.description,
56773
+ ...zcodeSection
56774
+ };
56775
+ const body = rulesyncSubagent.getBody();
56776
+ const fileContent = stringifyFrontmatter(body, zcodeFrontmatter, { avoidBlockScalars: true });
56777
+ const paths = this.getSettablePaths({ global });
56778
+ return new ZcodeSubagent({
56779
+ outputRoot,
56780
+ frontmatter: zcodeFrontmatter,
56781
+ body,
56782
+ relativeDirPath: paths.relativeDirPath,
56783
+ relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
56784
+ fileContent,
56785
+ validate,
56786
+ global
56787
+ });
56788
+ }
56789
+ validate() {
56790
+ if (!this.frontmatter) return {
56791
+ success: true,
56792
+ error: null
56793
+ };
56794
+ const result = ZcodeSubagentFrontmatterSchema.safeParse(this.frontmatter);
56795
+ if (result.success) return {
56796
+ success: true,
56797
+ error: null
56798
+ };
56799
+ else return {
56800
+ success: false,
56801
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
56802
+ };
56803
+ }
56804
+ static isTargetedByRulesyncSubagent(rulesyncSubagent) {
56805
+ return this.isTargetedByRulesyncSubagentDefault({
56806
+ rulesyncSubagent,
56807
+ toolTarget: "zcode"
56808
+ });
56809
+ }
56810
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true, global = false }) {
56811
+ const dirPath = relativeDirPath ?? this.getSettablePaths({ global }).relativeDirPath;
56812
+ const filePath = join(outputRoot, dirPath, relativeFilePath);
56813
+ const fileContent = await readFileContent(filePath);
56814
+ const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
56815
+ const result = ZcodeSubagentFrontmatterSchema.safeParse(frontmatter);
56816
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
56817
+ return new ZcodeSubagent({
56818
+ outputRoot,
56819
+ relativeDirPath: dirPath,
56820
+ relativeFilePath,
56821
+ frontmatter: result.data,
56822
+ body: content.trim(),
56823
+ fileContent,
56824
+ validate,
56825
+ global
56826
+ });
56827
+ }
56828
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
56829
+ return new ZcodeSubagent({
56830
+ outputRoot,
56831
+ relativeDirPath,
56832
+ relativeFilePath,
56833
+ frontmatter: {
56834
+ name: "",
56835
+ description: ""
56836
+ },
56837
+ body: "",
56838
+ fileContent: "",
56839
+ validate: false
56840
+ });
56841
+ }
56842
+ };
56843
+ //#endregion
54635
56844
  //#region src/features/subagents/zoocode-subagent.ts
54636
56845
  /**
54637
56846
  * Subagent (custom-mode) generator for **Zoo Code**, the community
@@ -54696,6 +56905,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54696
56905
  ["agentsmd", {
54697
56906
  class: AgentsmdSubagent,
54698
56907
  meta: {
56908
+ supportsProject: true,
54699
56909
  supportsSimulated: true,
54700
56910
  supportsGlobal: false,
54701
56911
  filePattern: "*.md"
@@ -54704,6 +56914,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54704
56914
  ["antigravity-cli", {
54705
56915
  class: AntigravityCliSubagent,
54706
56916
  meta: {
56917
+ supportsProject: true,
54707
56918
  supportsSimulated: false,
54708
56919
  supportsGlobal: true,
54709
56920
  filePattern: "*.md"
@@ -54712,6 +56923,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54712
56923
  ["antigravity-ide", {
54713
56924
  class: AntigravityIdeSubagent,
54714
56925
  meta: {
56926
+ supportsProject: true,
54715
56927
  supportsSimulated: false,
54716
56928
  supportsGlobal: true,
54717
56929
  filePattern: "*.md"
@@ -54720,6 +56932,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54720
56932
  ["antigravity-plugin", {
54721
56933
  class: AntigravityPluginSubagent,
54722
56934
  meta: {
56935
+ supportsProject: true,
54723
56936
  supportsSimulated: false,
54724
56937
  supportsGlobal: false,
54725
56938
  filePattern: "*.md"
@@ -54728,6 +56941,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54728
56941
  ["augmentcode", {
54729
56942
  class: AugmentcodeSubagent,
54730
56943
  meta: {
56944
+ supportsProject: true,
54731
56945
  supportsSimulated: false,
54732
56946
  supportsGlobal: true,
54733
56947
  filePattern: "*.md"
@@ -54736,6 +56950,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54736
56950
  ["claudecode", {
54737
56951
  class: ClaudecodeSubagent,
54738
56952
  meta: {
56953
+ supportsProject: true,
54739
56954
  supportsSimulated: false,
54740
56955
  supportsGlobal: true,
54741
56956
  filePattern: "*.md"
@@ -54744,6 +56959,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54744
56959
  ["claudecode-plugin", {
54745
56960
  class: ClaudecodePluginSubagent,
54746
56961
  meta: {
56962
+ supportsProject: true,
54747
56963
  supportsSimulated: false,
54748
56964
  supportsGlobal: false,
54749
56965
  filePattern: "*.md"
@@ -54752,6 +56968,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54752
56968
  ["claudecode-legacy", {
54753
56969
  class: ClaudecodeSubagent,
54754
56970
  meta: {
56971
+ supportsProject: true,
54755
56972
  supportsSimulated: false,
54756
56973
  supportsGlobal: true,
54757
56974
  filePattern: "*.md"
@@ -54760,6 +56977,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54760
56977
  ["cline", {
54761
56978
  class: ClineSubagent,
54762
56979
  meta: {
56980
+ supportsProject: true,
54763
56981
  supportsSimulated: false,
54764
56982
  supportsGlobal: true,
54765
56983
  filePattern: "*.{yaml,yml}"
@@ -54768,6 +56986,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54768
56986
  ["codexcli", {
54769
56987
  class: CodexCliSubagent,
54770
56988
  meta: {
56989
+ supportsProject: true,
54771
56990
  supportsSimulated: false,
54772
56991
  supportsGlobal: true,
54773
56992
  filePattern: "*.toml"
@@ -54776,6 +56995,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54776
56995
  ["copilot", {
54777
56996
  class: CopilotSubagent,
54778
56997
  meta: {
56998
+ supportsProject: true,
54779
56999
  supportsSimulated: false,
54780
57000
  supportsGlobal: true,
54781
57001
  filePattern: "*.md"
@@ -54784,6 +57004,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54784
57004
  ["copilotcli", {
54785
57005
  class: CopilotcliSubagent,
54786
57006
  meta: {
57007
+ supportsProject: true,
54787
57008
  supportsSimulated: false,
54788
57009
  supportsGlobal: true,
54789
57010
  filePattern: "*.agent.md"
@@ -54792,6 +57013,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54792
57013
  ["cursor", {
54793
57014
  class: CursorSubagent,
54794
57015
  meta: {
57016
+ supportsProject: true,
54795
57017
  supportsSimulated: false,
54796
57018
  supportsGlobal: true,
54797
57019
  filePattern: "*.md"
@@ -54800,6 +57022,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54800
57022
  ["deepagents", {
54801
57023
  class: DeepagentsSubagent,
54802
57024
  meta: {
57025
+ supportsProject: true,
54803
57026
  supportsSimulated: false,
54804
57027
  supportsGlobal: true,
54805
57028
  filePattern: join("*", "AGENTS.md")
@@ -54808,6 +57031,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54808
57031
  ["devin", {
54809
57032
  class: DevinSubagent,
54810
57033
  meta: {
57034
+ supportsProject: true,
54811
57035
  supportsSimulated: false,
54812
57036
  supportsGlobal: true,
54813
57037
  filePattern: join("*", "AGENT.md")
@@ -54816,6 +57040,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54816
57040
  ["factorydroid", {
54817
57041
  class: FactorydroidSubagent,
54818
57042
  meta: {
57043
+ supportsProject: true,
54819
57044
  supportsSimulated: false,
54820
57045
  supportsGlobal: true,
54821
57046
  filePattern: "*.md"
@@ -54824,6 +57049,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54824
57049
  ["goose", {
54825
57050
  class: GooseSubagent,
54826
57051
  meta: {
57052
+ supportsProject: true,
54827
57053
  supportsSimulated: false,
54828
57054
  supportsGlobal: true,
54829
57055
  filePattern: "*.md"
@@ -54832,6 +57058,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54832
57058
  ["hermesagent", {
54833
57059
  class: HermesagentSubagent,
54834
57060
  meta: {
57061
+ supportsProject: true,
54835
57062
  supportsGlobal: true,
54836
57063
  supportsSimulated: false,
54837
57064
  filePattern: "*.json"
@@ -54840,6 +57067,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54840
57067
  ["grokcli", {
54841
57068
  class: GrokcliSubagent,
54842
57069
  meta: {
57070
+ supportsProject: true,
54843
57071
  supportsSimulated: false,
54844
57072
  supportsGlobal: true,
54845
57073
  filePattern: "*.md"
@@ -54848,6 +57076,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54848
57076
  ["junie", {
54849
57077
  class: JunieSubagent,
54850
57078
  meta: {
57079
+ supportsProject: true,
54851
57080
  supportsSimulated: false,
54852
57081
  supportsGlobal: true,
54853
57082
  filePattern: "*.md"
@@ -54856,6 +57085,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54856
57085
  ["kiro", {
54857
57086
  class: KiroSubagent,
54858
57087
  meta: {
57088
+ supportsProject: true,
54859
57089
  supportsSimulated: false,
54860
57090
  supportsGlobal: false,
54861
57091
  filePattern: "*.json"
@@ -54864,6 +57094,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54864
57094
  ["kiro-cli", {
54865
57095
  class: KiroCliSubagent,
54866
57096
  meta: {
57097
+ supportsProject: true,
54867
57098
  supportsSimulated: false,
54868
57099
  supportsGlobal: true,
54869
57100
  filePattern: "*.json"
@@ -54872,6 +57103,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54872
57103
  ["kiro-ide", {
54873
57104
  class: KiroIdeSubagent,
54874
57105
  meta: {
57106
+ supportsProject: true,
54875
57107
  supportsSimulated: false,
54876
57108
  supportsGlobal: true,
54877
57109
  filePattern: "*.md"
@@ -54880,6 +57112,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54880
57112
  ["kilo", {
54881
57113
  class: KiloSubagent,
54882
57114
  meta: {
57115
+ supportsProject: true,
54883
57116
  supportsSimulated: false,
54884
57117
  supportsGlobal: true,
54885
57118
  filePattern: "*.md"
@@ -54888,6 +57121,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54888
57121
  ["kimi-code", {
54889
57122
  class: KimiCodeSubagent,
54890
57123
  meta: {
57124
+ supportsProject: true,
54891
57125
  supportsSimulated: false,
54892
57126
  supportsGlobal: true,
54893
57127
  filePattern: join("**", "*.md")
@@ -54896,6 +57130,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54896
57130
  ["opencode", {
54897
57131
  class: OpenCodeSubagent,
54898
57132
  meta: {
57133
+ supportsProject: true,
54899
57134
  supportsSimulated: false,
54900
57135
  supportsGlobal: true,
54901
57136
  filePattern: "*.md"
@@ -54904,6 +57139,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54904
57139
  ["qwencode", {
54905
57140
  class: QwencodeSubagent,
54906
57141
  meta: {
57142
+ supportsProject: true,
54907
57143
  supportsSimulated: false,
54908
57144
  supportsGlobal: true,
54909
57145
  filePattern: "*.md"
@@ -54912,6 +57148,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54912
57148
  ["reasonix", {
54913
57149
  class: ReasonixSubagent,
54914
57150
  meta: {
57151
+ supportsProject: true,
54915
57152
  supportsSimulated: false,
54916
57153
  supportsGlobal: true,
54917
57154
  filePattern: join("*", "SKILL.md")
@@ -54920,6 +57157,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54920
57157
  ["roo", {
54921
57158
  class: RooSubagent,
54922
57159
  meta: {
57160
+ supportsProject: true,
54923
57161
  supportsSimulated: false,
54924
57162
  supportsGlobal: false,
54925
57163
  filePattern: ".roomodes"
@@ -54928,6 +57166,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54928
57166
  ["zoocode", {
54929
57167
  class: ZoocodeSubagent,
54930
57168
  meta: {
57169
+ supportsProject: true,
54931
57170
  supportsSimulated: false,
54932
57171
  supportsGlobal: false,
54933
57172
  filePattern: ".roomodes"
@@ -54936,6 +57175,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54936
57175
  ["rovodev", {
54937
57176
  class: RovodevSubagent,
54938
57177
  meta: {
57178
+ supportsProject: true,
54939
57179
  supportsSimulated: false,
54940
57180
  supportsGlobal: true,
54941
57181
  filePattern: "*.md"
@@ -54944,6 +57184,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54944
57184
  ["takt", {
54945
57185
  class: TaktSubagent,
54946
57186
  meta: {
57187
+ supportsProject: true,
54947
57188
  supportsSimulated: false,
54948
57189
  supportsGlobal: true,
54949
57190
  filePattern: "*.md"
@@ -54952,10 +57193,20 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
54952
57193
  ["vibe", {
54953
57194
  class: VibeSubagent,
54954
57195
  meta: {
57196
+ supportsProject: true,
54955
57197
  supportsSimulated: false,
54956
57198
  supportsGlobal: true,
54957
57199
  filePattern: "*.toml"
54958
57200
  }
57201
+ }],
57202
+ ["zcode", {
57203
+ class: ZcodeSubagent,
57204
+ meta: {
57205
+ supportsProject: false,
57206
+ supportsSimulated: false,
57207
+ supportsGlobal: true,
57208
+ filePattern: "*.md"
57209
+ }
54959
57210
  }]
54960
57211
  ]);
54961
57212
  const defaultGetFactory$1 = (target) => {
@@ -54964,7 +57215,9 @@ const defaultGetFactory$1 = (target) => {
54964
57215
  return factory;
54965
57216
  };
54966
57217
  const allToolTargetKeys$1 = [...toolSubagentFactories.keys()];
54967
- const subagentsProcessorToolTargets = allToolTargetKeys$1;
57218
+ const subagentsProcessorToolTargets = allToolTargetKeys$1.filter((target) => {
57219
+ return toolSubagentFactories.get(target)?.meta.supportsProject ?? false;
57220
+ });
54968
57221
  const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((target) => {
54969
57222
  return toolSubagentFactories.get(target)?.meta.supportsSimulated ?? false;
54970
57223
  });
@@ -55064,7 +57317,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
55064
57317
  this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
55065
57318
  return [];
55066
57319
  }
55067
- const mdFiles = (await listDirectoryFiles(subagentsDir)).filter((file) => file.endsWith(".md"));
57320
+ const mdFiles = (await listDirectoryEntryNames(subagentsDir)).filter((file) => file.endsWith(".md"));
55068
57321
  if (mdFiles.length === 0) {
55069
57322
  this.logger.debug(`No markdown files found in rulesync subagents directory: ${subagentsDir}`);
55070
57323
  return [];
@@ -63610,6 +65863,6 @@ async function importChecksCore(params) {
63610
65863
  return writtenCount;
63611
65864
  }
63612
65865
  //#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 };
65866
+ export { SHARED_USER_MANAGED_CONFIG_PATHS as $, MAX_FILE_SIZE as $t, groupSpellingsByCaseFoldedIdentity as A, DEPRECATED_FEATURE_REPLACEMENTS as An, isFileSystemError as At, RulesyncPermissions as B, removeFile as Bt, CLAUDECODE_SKILLS_DIR_PATH as C, RULESYNC_RULES_RELATIVE_DIR_PATH as Cn, createTempDirectory as Ct, FACTORYDROID_DIR as D, parseCommaSeparatedList as Dn, getFileSize as Dt, CODEXCLI_DIR as E, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as En, fileExists as Et, RulesyncSubagentFrontmatterSchema as F, stripControlCharactersKeepingLineFeeds as Fn, pathEscapesRoot as Ft, resolveRulesyncSourceWritePath as G, toPosixPath as Gt, RulesyncIgnore as H, removeTempDirectory as Ht, RulesyncSkill as I, stripHiddenCharacters as In, readFileContent as It, RulesyncCommandFrontmatterSchema as J, ALL_TOOL_TARGETS as Jt, parseJsonc as K, writeFileBuffer as Kt, RulesyncSkillFrontmatterSchema as L, readFileContentOrNull as Lt, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as M, truncateText as Mn, listDirectoryEntryNames as Mt, getLocalSkillDirNames as N, hasDeceptiveHiddenCharacters as Nn, listFilePathsRecursively as Nt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as O, ALL_FEATURES as On, getHomeDirectory as Ot, RulesyncSubagent as P, stripControlCharacters as Pn, listSubdirectoryNames as Pt, loadYaml as Q, CURATED_RULES_FEATURE_SUBDIR as Qt, RulesyncRule as R, removeDirectory as Rt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as S, RULESYNC_RELATIVE_DIR_PATH as Sn, checkPathTraversal as St, CODEXCLI_BASH_RULES_FILE_NAME as T, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Tn, ensureDir as Tt, RulesyncHooks as U, resolvePath as Ut, RulesyncMcp as V, removeFileStrict as Vt, getRulesyncSourceCandidates as W, runWithDirectoryRollback as Wt, RulesyncCheckFrontmatterSchema as X, PACKAGING_TOOL_TARGETS as Xt, RulesyncCheck as Y, ALL_TOOL_TARGETS_WITH_WILDCARD as Yt, stringifyFrontmatter as Z, ToolTargetSchema as Zt, QWENCODE_DIR as _, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as _n, CLIError as _t, getProcessorRegistryEntry as a, RULESYNC_CONFIG_SCHEMA_URL as an, ConfigFileSchema as at, CLAUDECODE_LOCAL_RULE_FILE_NAME as b, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as bn, assertTreeContainsNoSymlinks as bt, RulesProcessor as c, RULESYNC_HOOKS_FILE_NAME as cn, findControlCharacter as ct, displayWidthOf as d, RULESYNC_IGNORE_RELATIVE_FILE_PATH as dn, WarningCollectingLogger as dt, RULESYNC_AIIGNORE_FILE_NAME as en, SKILL_FILE_NAME as et, shortenToWidth as f, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as fn, fallbackLogger as ft, CommandsProcessor as g, RULESYNC_MCP_SCHEMA_URL as gn, withWarnOnceScope as gt, HooksProcessor as h, RULESYNC_MCP_RELATIVE_FILE_PATH as hn, resetWarnedOnceMessages as ht, inspectInputRoots as i, RULESYNC_CONFIG_RELATIVE_FILE_PATH as in, CONFLICTING_TARGET_PAIRS as it, AUGMENTCODE_DIR as j, formatError as jn, isSymlink as jt, caseFoldIdentity as k, ALL_FEATURES_WITH_WILDCARD as kn, isFileNotFoundError as kt, SubagentsProcessor as l, RULESYNC_HOOKS_LEGACY_FILE_NAME as ln, ConsoleLogger as lt, IgnoreProcessor as m, RULESYNC_MCP_LEGACY_FILE_NAME as mn, withFallbackLoggerTarget as mt, formatSourceLoadFailure as n, RULESYNC_CHECKS_RELATIVE_DIR_PATH as nn, mergeInputRootConfigs as nt, convertFromTool as o, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as on, GITIGNORE_DESTINATION_KEY as ot, McpProcessor as p, RULESYNC_MCP_FILE_NAME as pn, warnOnConflictingFlags as pt, RulesyncCommand as q, writeFileContent as qt, generate as r, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as rn, resolveEffectiveInputRoots as rt, isPackagingToolTarget as s, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as sn, SourceEntrySchema as st, importFromTool as t, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as tn, ConfigResolver as tt, SkillsProcessor as u, RULESYNC_HOOKS_RELATIVE_FILE_PATH as un, JsonLogger as ut, QWENCODE_LOCAL_RULE_FILE_NAME as v, RULESYNC_PERMISSIONS_FILE_NAME as vn, ErrorCodes as vt, ChecksProcessor as w, RULESYNC_SKILLS_RELATIVE_DIR_PATH as wn, directoryExists as wt, CLAUDECODE_MEMORIES_DIR_NAME as x, RULESYNC_PERMISSIONS_SCHEMA_URL as xn, assertWritablePathInsideRoot as xt, CLAUDECODE_DIR as y, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as yn, assertDirectoryIfExists as yt, RulesyncRuleFrontmatterSchema as z, removeDirectoryStrict as zt };
63614
65867
 
63615
- //# sourceMappingURL=import-BKnAc4rT.js.map
65868
+ //# sourceMappingURL=import-1-jjDDAm.js.map