jules-orchestrator-kit 0.72.2 → 0.73.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.
Files changed (80) hide show
  1. package/.agent/prompts/{Overseer.md → Auditor.md} +3 -3
  2. package/.agent/prompts/{Alchemist.md → Database.md} +1 -1
  3. package/.agent/prompts/Debugger.md +25 -0
  4. package/.agent/prompts/{Scribe.md → Docs.md} +8 -5
  5. package/.agent/prompts/{Spectator.md → E2E.md} +9 -6
  6. package/.agent/prompts/{Janitor.md → Hygiene.md} +2 -2
  7. package/.agent/prompts/{Bolt.md → Performance.md} +1 -1
  8. package/.agent/prompts/Resilience.md +20 -0
  9. package/.agent/prompts/Security.md +21 -0
  10. package/.agent/prompts/Testing.md +30 -0
  11. package/.agent/prompts/Types.md +19 -0
  12. package/.agent/rules/jules-protocol.md +4 -3
  13. package/AGENTS.md +78 -96
  14. package/CHANGELOG.md +193 -0
  15. package/JULES_RULES_TEMPLATE.md +83 -96
  16. package/LICENSE +1 -1
  17. package/README.md +77 -439
  18. package/ROADMAP_V1.md +22 -132
  19. package/bin/agentctl.mjs +443 -144
  20. package/bin/init.js +6 -3
  21. package/index.mjs +9 -6
  22. package/package.json +1 -1
  23. package/scripts/asset-integrity-check.mjs +1 -1
  24. package/scripts/doc-sync-check.mjs +47 -0
  25. package/scripts/generate-command-reference.mjs +39 -0
  26. package/scripts/jules-dispatch.mjs +12 -113
  27. package/scripts/jules-merge-swarm.mjs +8 -196
  28. package/scripts/jules-patch.mjs +7 -8
  29. package/scripts/jules-queue-runner.mjs +6 -8
  30. package/scripts/jules-scan-todos.mjs +10 -38
  31. package/scripts/jules-self-audit.mjs +8 -139
  32. package/scripts/jules-status.mjs +32 -38
  33. package/scripts/jules-webhook-receiver.mjs +1 -1
  34. package/src/assertions.mjs +5 -50
  35. package/src/bidi-guard.mjs +36 -0
  36. package/src/budget.mjs +3 -14
  37. package/src/config.mjs +2 -6
  38. package/src/dashboard.mjs +7 -9
  39. package/src/dispatch.mjs +212 -0
  40. package/src/engine.mjs +40 -16
  41. package/src/evidence.mjs +10 -41
  42. package/src/execution-envelope.mjs +13 -1
  43. package/src/flaky-ledger.mjs +1 -1
  44. package/src/fs-atomic.mjs +72 -0
  45. package/src/git.mjs +298 -27
  46. package/src/mcp.mjs +296 -7
  47. package/src/memory.mjs +0 -0
  48. package/src/merge-swarm.mjs +202 -0
  49. package/src/ops/cli-intent.mjs +1 -0
  50. package/src/ops/command-registry.mjs +796 -70
  51. package/src/ops/doctor-registry.mjs +134 -47
  52. package/src/ops/handover.mjs +3 -27
  53. package/src/ops/pr-harvest.mjs +1 -1
  54. package/src/prompt-guard.mjs +33 -3
  55. package/src/provider.mjs +51 -7
  56. package/src/remediation.mjs +2 -2
  57. package/src/role-resolver.mjs +113 -3
  58. package/src/router.mjs +19 -11
  59. package/src/runtime-env.mjs +67 -0
  60. package/src/scaffold.mjs +3 -1
  61. package/src/scope-guard.mjs +249 -0
  62. package/src/secret-scanner.mjs +530 -0
  63. package/src/security.mjs +81 -2972
  64. package/src/self-audit.mjs +140 -0
  65. package/src/session-ops.mjs +29 -0
  66. package/src/stability.mjs +8 -1
  67. package/src/stack-detector.mjs +5 -2
  68. package/src/state.mjs +45 -0
  69. package/src/swarm.mjs +76 -0
  70. package/src/task-optimizer.mjs +34 -7
  71. package/src/telemetry.mjs +23 -0
  72. package/src/test-tamper-guard.mjs +2173 -0
  73. package/src/todo-scanner.mjs +129 -0
  74. package/src/web-templates.mjs +3 -3
  75. package/src/webhook.mjs +10 -3
  76. package/src/wizard-init.mjs +12 -20
  77. package/src/wizard-oracle.mjs +4 -3
  78. package/src/wizard-task.mjs +37 -9
  79. package/.agent/prompts/Sentinel.md +0 -18
  80. package/scripts/utils.mjs +0 -241
package/src/router.mjs CHANGED
@@ -4,6 +4,7 @@ import { normalizePath } from "./config.mjs";
4
4
  import { matchesGlob } from "./security.mjs";
5
5
  import { extractPathTokens } from "./task-optimizer.mjs";
6
6
  import { createProvider, createFailoverProvider, createSyntaxVerifiedProvider } from "./provider.mjs";
7
+ import { ROLE_ALIASES, CANONICAL_ROLES } from "./role-resolver.mjs";
7
8
 
8
9
  /**
9
10
  * Dynamic Complexity & Cost Router (Roadmap v0.33.0).
@@ -71,10 +72,14 @@ const COMPLEX_SIGNALS = [
71
72
  /\bencrypt(ion)?\b/i,
72
73
  ];
73
74
 
74
- // Sentinel handles security-sensitive work; the primary provider is always used.
75
- const FORCE_COMPLEX_ROLES = new Set(["sentinel"]);
76
- const FAST_LEANING_ROLES = new Set(["janitor", "bolt"]);
77
- const COMPLEX_LEANING_ROLES = new Set(["overseer", "sentinel"]);
75
+ // Security specialists handle security-sensitive work; the primary provider is always used.
76
+ // Canonical slugs only: classifyTaskComplexity normalises the requested role
77
+ // through ROLE_ALIASES before these sets are consulted, so a legacy alias
78
+ // already arrives here as its canonical role. Listing legacy names here
79
+ // would be dead weight, not a fallback.
80
+ const FORCE_COMPLEX_ROLES = new Set(["security"]);
81
+ const FAST_LEANING_ROLES = new Set(["hygiene", "performance"]);
82
+ const COMPLEX_LEANING_ROLES = new Set(["auditor", "security"]);
78
83
 
79
84
  // Supplements config.scope.deny — these are never eligible for the fast tier
80
85
  // regardless of user scope config, mirroring src/risk.mjs's RESTRICTED_PATH_PATTERNS.
@@ -129,10 +134,13 @@ export function classifyTaskComplexity(task = {}, config = {}) {
129
134
  }
130
135
 
131
136
  const paths = collectReferencedPaths(task);
132
- const role = String(task.role || "").toLowerCase();
137
+ const rawRole = String(task.role || "").trim().toLowerCase();
138
+ const role = (ROLE_ALIASES[rawRole] && CANONICAL_ROLES.includes(ROLE_ALIASES[rawRole]))
139
+ ? ROLE_ALIASES[rawRole]
140
+ : rawRole;
133
141
 
134
- if (FORCE_COMPLEX_ROLES.has(role)) {
135
- return { tier: ROUTE_TIERS.COMPLEX, score: null, forced: true, reason: `Role '${role}' always routes to the primary provider` };
142
+ if (FORCE_COMPLEX_ROLES.has(role) || FORCE_COMPLEX_ROLES.has(rawRole)) {
143
+ return { tier: ROUTE_TIERS.COMPLEX, score: null, forced: true, reason: `Role '${task.role}' always routes to the primary provider` };
136
144
  }
137
145
 
138
146
  // 1. Declarative Asset Override: 100% declarative non-executable files bypass sensitive-path penalty
@@ -216,13 +224,13 @@ export function classifyTaskComplexity(task = {}, config = {}) {
216
224
  signals.push(`-1 short prompt (${promptLen} chars)`);
217
225
  }
218
226
 
219
- if (COMPLEX_LEANING_ROLES.has(role)) {
227
+ if (COMPLEX_LEANING_ROLES.has(role) || COMPLEX_LEANING_ROLES.has(rawRole)) {
220
228
  score += 1;
221
- signals.push(`+1 role '${role}' leans complex`);
229
+ signals.push(`+1 role '${task.role}' leans complex`);
222
230
  }
223
- if (FAST_LEANING_ROLES.has(role)) {
231
+ if (FAST_LEANING_ROLES.has(role) || FAST_LEANING_ROLES.has(rawRole)) {
224
232
  score -= 2;
225
- signals.push(`-2 role '${role}' leans fast`);
233
+ signals.push(`-2 role '${task.role}' leans fast`);
226
234
  }
227
235
 
228
236
  const threshold = Number.isFinite(config?.router?.threshold) ? config.router.threshold : 0;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Runtime environment helpers shared by the kit's CLI entry points.
3
+ *
4
+ * Extracted from the deleted `scripts/utils.mjs` shim: .env loading,
5
+ * timestamping, the console logger the CLI entries print through, and the
6
+ * isolated SDK cache directory. Nothing here is business logic; these are the
7
+ * small environment primitives entry scripts (and tests) need so they do not
8
+ * each carry their own copy.
9
+ */
10
+
11
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import os from "node:os";
14
+
15
+ export const log = {
16
+ info: (msg) => console.log(`ℹ️ ${msg}`),
17
+ success: (msg) => console.log(`✅ ${msg}`),
18
+ warn: (msg) => console.warn(`⚠️ ${msg}`),
19
+ error: (msg) => console.error(`❌ ${msg}`),
20
+ step: (stepStr, msg) => console.log(`${stepStr} ${msg}`),
21
+ dim: (msg) => console.log(msg),
22
+ header: (msg) => console.log(`\n=== ${msg} ===\n`),
23
+ groupEnd: () => {},
24
+ };
25
+
26
+ export function timestamp() {
27
+ return new Date().toISOString();
28
+ }
29
+
30
+ /**
31
+ * Loads KEY=VALUE lines from `<targetDir>/.env` into `process.env`.
32
+ * Quoted values and leading `export ` are tolerated; a missing file is a no-op.
33
+ */
34
+ export function loadEnv(targetDir = process.cwd()) {
35
+ const envPath = join(targetDir, ".env");
36
+ if (!existsSync(envPath)) return;
37
+ try {
38
+ const raw = readFileSync(envPath, "utf-8");
39
+ for (const line of raw.split(/\r?\n/)) {
40
+ const trimmed = line.trim();
41
+ if (!trimmed || trimmed.startsWith("#")) continue;
42
+ const clean = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
43
+ const eqIdx = clean.indexOf("=");
44
+ if (eqIdx > 0) {
45
+ const k = clean.slice(0, eqIdx).trim();
46
+ let v = clean.slice(eqIdx + 1).trim();
47
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
48
+ v = v.slice(1, -1);
49
+ }
50
+ process.env[k] = v;
51
+ }
52
+ }
53
+ } catch (_) {}
54
+ }
55
+
56
+ export function getIsolatedCacheDir() {
57
+ return process.env.JULES_CACHE_DIR ? resolve(process.env.JULES_CACHE_DIR) : join(os.homedir(), ".cache", "jules-orchestrator-kit");
58
+ }
59
+
60
+ export function ensureSdkCacheIsolation() {
61
+ const dir = getIsolatedCacheDir();
62
+ if (!existsSync(dir)) {
63
+ try { mkdirSync(dir, { recursive: true }); } catch (_) {}
64
+ }
65
+ process.env.JULES_CACHE_DIR = dir;
66
+ return dir;
67
+ }
package/src/scaffold.mjs CHANGED
@@ -21,6 +21,8 @@ export const RUNTIME_GITIGNORE_ENTRIES = [
21
21
  ".agent/state/",
22
22
  ".agent/evidence/",
23
23
  ".agent/handovers/",
24
+ ".agent/knowledge/",
25
+ ".agent/SYSTEM_LEARNINGS.md",
24
26
  ".agent/jules-queue/.state/",
25
27
  ".agent/jules-queue/failed/",
26
28
  ".agent/jules-queue/.processing/",
@@ -250,7 +252,7 @@ export function scaffoldRepoAssets(root = process.cwd(), options = {}) {
250
252
  }
251
253
 
252
254
  if (copyDir(join(KIT_ROOT, ".agent/prompts"), join(agentDir, "prompts"), force) > 0) {
253
- created.push(".agent/prompts/ (Overseer, Bolt, Sentinel, Janitor, A11y, Scribe, Spectator, Alchemist)");
255
+ created.push(".agent/prompts/ (Auditor, Performance, Security, Hygiene, Resilience, Types, Debugger, Testing, E2E, Database, Docs, A11y)");
254
256
  }
255
257
  if (copyDir(join(KIT_ROOT, ".agent/rules"), join(agentDir, "rules"), force) > 0) {
256
258
  created.push(".agent/rules/");
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Scope policy: which paths an agent may touch.
3
+ *
4
+ * Split out of src/security.mjs (P05). The glob matcher, the deny/protect
5
+ * matching and checkScope() form one job — deciding whether a path is inside the
6
+ * allowed scope — and none of it reads a diff or looks for a secret. The glob
7
+ * matcher is deliberately regex-free and is imported by assertions.mjs, risk.mjs,
8
+ * router.mjs and self-audit.mjs as well as by the scope rules below.
9
+ */
10
+
11
+ import { basename } from "node:path";
12
+ import { canonicalizePath, isWindowsAbsolutePath } from "./config.mjs";
13
+
14
+ /**
15
+ * Glob matcher.
16
+ *
17
+ * `caseInsensitive` exists because the same repository is checked out on
18
+ * Linux, macOS and Windows. On APFS and NTFS, `.GitHub/` and `.github/` are
19
+ * the *same directory*, but git records whichever case was committed — so a
20
+ * case-sensitive deny rule can be walked straight past on two of the three
21
+ * target platforms. Deny and protect matching therefore folds case; allow
22
+ * matching deliberately does not, so that a case mismatch fails closed
23
+ * (unmatched by allow = violation) rather than opening a hole.
24
+ *
25
+ * @param {string} filePath
26
+ * @param {string} globPattern
27
+ * @param {{ caseInsensitive?: boolean }} [opts]
28
+ */
29
+ /**
30
+ * Matches one `/`-free glob segment against one `/`-free path segment.
31
+ *
32
+ * Only `*` (zero or more characters) and `?` (exactly one) are special; every
33
+ * other character — including regex metacharacters like `(`, `+`, `.`, `[` —
34
+ * is matched literally, preserving the escaping behaviour the old regex
35
+ * translation had. A segment that is exactly `*` keeps its historical one-or-
36
+ * more semantics, so `*` cannot match an empty segment.
37
+ *
38
+ * Implemented with the classic greedy-star wildcard algorithm rather than a
39
+ * compiled regex: a segment like `*a*a*a*a*a*a*b` translated to
40
+ * `^[^/]*a[^/]*a…$` and backtracked exponentially on a long run of `a`s, so
41
+ * this path must never build a regex. The algorithm scans each character a
42
+ * bounded number of times and cannot blow up the way the regex could.
43
+ *
44
+ * @param {string} str
45
+ * @param {string} pattern
46
+ * @param {boolean} [caseInsensitive]
47
+ * @returns {boolean}
48
+ */
49
+ function matchGlobSegment(str, pattern, caseInsensitive = false) {
50
+ if (pattern === "*") return str.length > 0;
51
+
52
+ let s = caseInsensitive ? str.toLowerCase() : str;
53
+ let p = caseInsensitive ? pattern.toLowerCase() : pattern;
54
+
55
+ let si = 0;
56
+ let pi = 0;
57
+ let star = -1;
58
+ let matchIdx = 0;
59
+
60
+ while (si < s.length) {
61
+ if (pi < p.length && (p[pi] === "?" || p[pi] === s[si])) {
62
+ si++;
63
+ pi++;
64
+ } else if (pi < p.length && p[pi] === "*") {
65
+ star = pi;
66
+ matchIdx = si;
67
+ pi++;
68
+ } else if (star !== -1) {
69
+ pi = star + 1;
70
+ matchIdx++;
71
+ si = matchIdx;
72
+ } else {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ while (pi < p.length && p[pi] === "*") pi++;
78
+ return pi === p.length;
79
+ }
80
+
81
+ /**
82
+ * Linear-time glob matcher over `/`-split segments.
83
+ *
84
+ * `**` matches zero or more whole segments; every other pattern segment
85
+ * matches exactly one path segment via `matchGlobSegment`. This is a
86
+ * bottom-up dynamic program with a rolling array: O(n·m) time and O(m) memory
87
+ * for n path and m pattern segments, and it builds no regex at all.
88
+ *
89
+ * The previous implementation translated `**` into overlapping dot-star and
90
+ * start-anchored `(?: … |^)` alternations (`SEC-01`). Anchored against `$`, a
91
+ * pattern like `*a*a*a*a*a*a*a*a*b` or a chain of globstars caused catastrophic
92
+ * backtracking — the match time grew exponentially with input length and a
93
+ * hostile deny rule or file list could stall the dispatch gate. The DP
94
+ * replaces every one of those constructs with a bounded scan.
95
+ *
96
+ * @param {string[]} pathSegs
97
+ * @param {string[]} patSegs
98
+ * @param {boolean} [caseInsensitive]
99
+ * @returns {boolean}
100
+ */
101
+ function matchGlobSegments(pathSegs, patSegs, caseInsensitive = false) {
102
+ const n = pathSegs.length;
103
+ const m = patSegs.length;
104
+
105
+ // next[j] answers "does pathSegs[i+1..] match patSegs[j..]?". Seeded for the
106
+ // empty-path row (i = n): only true when every remaining pattern segment is
107
+ // `**`, since those are the only segments that can match zero path segments.
108
+ let next = new Array(m + 1).fill(false);
109
+ next[m] = true;
110
+ for (let j = m - 1; j >= 0; j--) {
111
+ next[j] = patSegs[j] === "**" && next[j + 1];
112
+ }
113
+
114
+ for (let i = n - 1; i >= 0; i--) {
115
+ const cur = new Array(m + 1).fill(false);
116
+ // cur[m] stays false: a path segment remains but the pattern is exhausted.
117
+ for (let j = m - 1; j >= 0; j--) {
118
+ if (patSegs[j] === "**") {
119
+ // Consume this segment and keep `**` (next[j]), or match zero segments
120
+ // and move on (cur[j + 1]).
121
+ cur[j] = next[j] || cur[j + 1];
122
+ } else if (matchGlobSegment(pathSegs[i], patSegs[j], caseInsensitive)) {
123
+ cur[j] = next[j + 1];
124
+ }
125
+ }
126
+ next = cur;
127
+ }
128
+
129
+ return next[0];
130
+ }
131
+
132
+ export function matchesGlob(filePath, globPattern, opts = {}) {
133
+ if (!filePath || !globPattern) return false;
134
+ const file = canonicalizePath(filePath);
135
+ const pattern = canonicalizePath(globPattern);
136
+ const caseInsensitive = Boolean(opts.caseInsensitive);
137
+
138
+ if (caseInsensitive ? file.toLowerCase() === pattern.toLowerCase() : file === pattern) return true;
139
+
140
+ return matchGlobSegments(file.split("/"), pattern.split("/"), caseInsensitive);
141
+ }
142
+
143
+ export function isForbiddenPath(filePath, config = {}) {
144
+ const normFile = canonicalizePath(filePath);
145
+ const forbidden = config.scope?.deny || config.forbidden_paths || [];
146
+ return forbidden.some((pattern) => matchesGlob(normFile, pattern, { caseInsensitive: true }));
147
+ }
148
+
149
+ /**
150
+ * The builtin deny patterns that exist to keep credentials out of a diff, and
151
+ * the documented template filenames those patterns must not catch.
152
+ *
153
+ * The recursive dot-env glob is correct for `.env.local` and `.env.production`
154
+ * and wrong for `.env.example` — a file nearly every repository commits
155
+ * precisely so the environment can be documented without the values. Denying it
156
+ * meant no agent could ever be asked to document a new variable, in any project.
157
+ *
158
+ * The exemption is deliberately narrow. It applies only when one of the two
159
+ * *builtin* patterns matched: a repository that writes its own broader dot-env
160
+ * deny rule blocks templates too, because the pattern string is not one of
161
+ * these. And the diff secret scanner runs over every changed file regardless of
162
+ * scope, so a real credential pasted into a template still fails on exit 6.
163
+ */
164
+ const BUILTIN_ENV_DENY_PATTERNS = new Set(["**/.env", "**/.env.*"]);
165
+ export const ENV_TEMPLATE_BASENAMES = new Set([
166
+ ".env.example",
167
+ ".env.sample",
168
+ ".env.template",
169
+ ".env.dist",
170
+ ".env.defaults",
171
+ ]);
172
+
173
+ /**
174
+ * True when a deny hit is the builtin credential rule catching a committed
175
+ * environment *template* rather than an environment file.
176
+ *
177
+ * @param {string} file - canonicalised repo-relative path
178
+ * @param {string} pattern - the deny pattern that matched
179
+ * @returns {boolean}
180
+ */
181
+ export function isEnvTemplateException(file, pattern) {
182
+ if (!BUILTIN_ENV_DENY_PATTERNS.has(pattern)) return false;
183
+ const name = basename(file).toLowerCase();
184
+ if (ENV_TEMPLATE_BASENAMES.has(name)) return true;
185
+ // `.env.production.example`, `.env.test.sample`, ... — the documented-template
186
+ // suffix is what matters, not how many environment segments precede it.
187
+ return /^\.env\..+\.(example|sample|template|dist|defaults)$/.test(name);
188
+ }
189
+
190
+ export function checkScope(files = [], scope = {}, opts = {}) {
191
+ const violations = [];
192
+ const deny = scope.deny || [];
193
+ const allow = scope.allow || [];
194
+ const protect = scope.protect || [];
195
+
196
+ for (const rawFile of files) {
197
+ // Canonicalised so that "./x", "a/../x" and "a//x" cannot present the same
198
+ // file under a spelling the deny patterns do not literally match.
199
+ const file = canonicalizePath(rawFile);
200
+
201
+ // A path that climbs out of the repository root can never be legitimate and
202
+ // must not be silently pattern-matched against repo-relative rules. This
203
+ // covers every spelling: POSIX absolute (`/etc/passwd`) and traversal
204
+ // (`../`, `..`), Windows drive-qualified and drive-relative (`C:\...`,
205
+ // `C:/...`, `C:foo`), and UNC (`\\server\share`, `//server/share`). The raw
206
+ // spelling is checked as well as the canonical one, because canonicalisation
207
+ // folds a leading `//` UNC into `/` and the check must fail on both.
208
+ if (
209
+ file === ".." ||
210
+ file.startsWith("../") ||
211
+ file.startsWith("/") ||
212
+ isWindowsAbsolutePath(rawFile) ||
213
+ isWindowsAbsolutePath(file)
214
+ ) {
215
+ violations.push({ file, reason: "Path escapes the repository root", rule: "deny", pattern: "<traversal>" });
216
+ continue;
217
+ }
218
+
219
+ // Deny folds case: on macOS/Windows ".GitHub/" resolves to the same
220
+ // directory as ".github/", so a case-sensitive deny is bypassable there.
221
+ const matchedDeny = deny.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
222
+ if (matchedDeny && !isEnvTemplateException(file, matchedDeny)) {
223
+ violations.push({ file, reason: `Forbidden path restriction matched pattern "${matchedDeny}"`, rule: "deny", pattern: matchedDeny });
224
+ continue;
225
+ }
226
+
227
+ // Allow stays case-sensitive on purpose: a case mismatch here yields "not
228
+ // allowed" (a violation), which is the fail-closed direction.
229
+ if (allow.length > 0) {
230
+ const isExplicitlyAllowed = allow.some((pat) => matchesGlob(file, pat));
231
+ if (!isExplicitlyAllowed) {
232
+ violations.push({ file, reason: "Path not included in allowed paths list", rule: "allow" });
233
+ continue;
234
+ }
235
+ }
236
+
237
+ if (!opts.allowProtected) {
238
+ const matchedProtect = protect.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
239
+ if (matchedProtect) {
240
+ violations.push({ file, reason: `Protected file modification restriction matched pattern "${matchedProtect}"`, rule: "protect", pattern: matchedProtect });
241
+ }
242
+ }
243
+ }
244
+
245
+ return {
246
+ ok: violations.length === 0,
247
+ violations,
248
+ };
249
+ }