jules-orchestrator-kit 0.8.5 → 0.9.4

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 (43) hide show
  1. package/.agent/jules-queue/README.md +4 -4
  2. package/.agent/prompts/Bolt.md +18 -0
  3. package/.agent/prompts/Overseer.md +18 -0
  4. package/.agent/prompts/Sentinel.md +18 -0
  5. package/.agent/prompts/Task_Template.md +26 -0
  6. package/JULES_RULES_TEMPLATE.md +6 -1
  7. package/README.md +54 -31
  8. package/bin/agentctl.mjs +281 -0
  9. package/bin/mcp-server.mjs +5 -0
  10. package/index.mjs +32 -5
  11. package/package.json +13 -2
  12. package/scripts/asset-integrity-check.mjs +35 -0
  13. package/scripts/command-resolver.mjs +37 -214
  14. package/scripts/jules-cleanup.mjs +23 -164
  15. package/scripts/jules-create.mjs +12 -56
  16. package/scripts/jules-dispatch.mjs +103 -449
  17. package/scripts/jules-merge-swarm.mjs +19 -173
  18. package/scripts/jules-nightly.mjs +7 -128
  19. package/scripts/jules-patch.mjs +15 -103
  20. package/scripts/jules-queue-runner.mjs +28 -221
  21. package/scripts/jules-scan-todos.mjs +31 -125
  22. package/scripts/jules-self-audit.mjs +69 -656
  23. package/scripts/jules-status.mjs +42 -70
  24. package/scripts/jules-swarm.mjs +23 -327
  25. package/scripts/jules-webhook-receiver.mjs +70 -0
  26. package/scripts/lock-manager.mjs +11 -177
  27. package/scripts/risk-tier.mjs +29 -0
  28. package/scripts/rules-lint.mjs +20 -0
  29. package/scripts/stale-base-check.mjs +26 -0
  30. package/scripts/utils.mjs +107 -369
  31. package/scripts/validate-envelope.mjs +44 -0
  32. package/src/asset_integrity.mjs +76 -0
  33. package/src/config.mjs +325 -0
  34. package/src/engine.mjs +217 -0
  35. package/src/envelope.mjs +105 -0
  36. package/src/git.mjs +127 -0
  37. package/src/mcp.mjs +211 -0
  38. package/src/provider.mjs +179 -0
  39. package/src/risk.mjs +120 -0
  40. package/src/rules_budget.mjs +77 -0
  41. package/src/security.mjs +212 -0
  42. package/src/state.mjs +217 -0
  43. package/src/webhook.mjs +138 -0
package/src/git.mjs ADDED
@@ -0,0 +1,127 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { sep } from "node:path";
3
+
4
+ export class GateError extends Error {
5
+ constructor(message, opts = {}) {
6
+ super(message);
7
+ this.name = "GateError";
8
+ this.code = opts.code || 1;
9
+ }
10
+ }
11
+
12
+ function normalizePath(p) {
13
+ if (!p || typeof p !== "string") return "";
14
+ return p.split(sep).join("/").replace(/\\/g, "/");
15
+ }
16
+
17
+ export function runCmd(command, opts = {}) {
18
+ const cwd = opts.cwd || process.cwd();
19
+ try {
20
+ const stdout = execFileSync("sh", ["-c", command], {
21
+ cwd,
22
+ encoding: "utf-8",
23
+ stdio: ["ignore", "pipe", "pipe"],
24
+ });
25
+ return { status: 0, stdout: stdout.trim(), stderr: "" };
26
+ } catch (err) {
27
+ if (opts.ignoreError) {
28
+ return {
29
+ status: err.status || 1,
30
+ stdout: (err.stdout || "").toString().trim(),
31
+ stderr: (err.stderr || "").toString().trim(),
32
+ };
33
+ }
34
+ throw err;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Shell-safe git invoker using direct argument arrays (shell: false).
40
+ */
41
+ export function git(args = [], opts = {}) {
42
+ const cwd = opts.cwd || process.cwd();
43
+ const argArray = Array.isArray(args) ? args : String(args).split(" ").filter(Boolean);
44
+ try {
45
+ const stdout = execFileSync("git", argArray, {
46
+ cwd,
47
+ encoding: "utf-8",
48
+ shell: false,
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ });
51
+ return opts.raw ? stdout : stdout.trim();
52
+ } catch (err) {
53
+ if (opts.ignoreError) {
54
+ return "";
55
+ }
56
+ const stderr = (err.stderr || err.message || "").toString().trim();
57
+ throw new GateError(`Git command failed [git ${argArray.join(" ")}]: ${stderr}`);
58
+ }
59
+ }
60
+
61
+ export function resolveBase(root = process.cwd(), baseRef = "main") {
62
+ const candidates = [`origin/${baseRef}`, baseRef, `refs/remotes/origin/${baseRef}`, "HEAD"];
63
+ for (const ref of candidates) {
64
+ try {
65
+ const res = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
66
+ cwd: root,
67
+ encoding: "utf-8",
68
+ shell: false,
69
+ stdio: ["ignore", "pipe", "ignore"],
70
+ });
71
+ if (res && res.trim()) {
72
+ return ref;
73
+ }
74
+ } catch (_) {}
75
+ }
76
+ throw new GateError(
77
+ `Cannot resolve base reference "${baseRef}". Ensure base branch is fetched (e.g., fetch-depth: 0 in CI).`,
78
+ { code: 1 }
79
+ );
80
+ }
81
+
82
+ export function changedFiles(root = process.cwd(), base = "main") {
83
+ const resolvedRef = resolveBase(root, base);
84
+ const raw = git(["-c", "core.quotePath=false", "diff", "-z", "--name-only", `${resolvedRef}...HEAD`], {
85
+ cwd: root,
86
+ raw: true,
87
+ });
88
+ return raw
89
+ .split("\0")
90
+ .map(normalizePath)
91
+ .filter(Boolean);
92
+ }
93
+
94
+ export function diffBytes(root = process.cwd(), base = "main") {
95
+ const resolvedRef = resolveBase(root, base);
96
+ const raw = git(["diff", "--shortstat", `${resolvedRef}...HEAD`], { cwd: root });
97
+ if (!raw) return 0;
98
+ const diffStr = git(["diff", `${resolvedRef}...HEAD`], { cwd: root, raw: true });
99
+ return Buffer.byteLength(diffStr, "utf-8");
100
+ }
101
+
102
+ export function diffText(root = process.cwd(), base = "main") {
103
+ const resolvedRef = resolveBase(root, base);
104
+ return git(["diff", `${resolvedRef}...HEAD`], { cwd: root, raw: true });
105
+ }
106
+
107
+ export function showFromOrigin(root = process.cwd(), base = "main", filePath = "") {
108
+ const resolvedRef = resolveBase(root, base);
109
+ const normPath = normalizePath(filePath);
110
+ try {
111
+ return git(["show", `${resolvedRef}:${normPath}`], { cwd: root, raw: true });
112
+ } catch (_) {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ export function worktreeAdd(root = process.cwd(), branch = "agent/task", targetDir = "") {
118
+ return git(["worktree", "add", targetDir, "-b", branch], { cwd: root });
119
+ }
120
+
121
+ export function worktreeRemove(root = process.cwd(), targetDir = "") {
122
+ return git(["worktree", "remove", targetDir, "--force"], { cwd: root });
123
+ }
124
+
125
+ export function worktreePrune(root = process.cwd()) {
126
+ return git(["worktree", "prune"], { cwd: root });
127
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,211 @@
1
+ import { createInterface } from "node:readline";
2
+ import { loadConfig, resolveRoot, detectStack } from "./config.mjs";
3
+ import { gate, dispatch } from "./engine.mjs";
4
+ import { classifyRiskTier } from "./risk.mjs";
5
+ import { checkDailyBudget, lockStatus } from "./state.mjs";
6
+
7
+ export const MCP_SERVER_INFO = {
8
+ name: "jules-orchestrator-kit",
9
+ version: "0.9.4",
10
+ };
11
+
12
+ export const MCP_TOOLS = [
13
+ {
14
+ name: "dispatch_jules_task",
15
+ description: "Dispatch an autonomous task to the Jules AI agent provider with security bounds and budget check.",
16
+ inputSchema: {
17
+ type: "object",
18
+ properties: {
19
+ title: { type: "string", description: "Short descriptive title for the task" },
20
+ prompt: { type: "string", description: "Detailed task instructions and prompt" },
21
+ },
22
+ required: ["prompt"],
23
+ },
24
+ },
25
+ {
26
+ name: "audit_jules_gate",
27
+ description: "Run the 4-phase safety gatekeeper (Scope, Payload, Secrets, Verify) against the current workspace branch.",
28
+ inputSchema: {
29
+ type: "object",
30
+ properties: {
31
+ base: { type: "string", description: "Base branch to diff against (defaults to main)" },
32
+ fix: { type: "boolean", description: "Attempt OODA auto-repair loop on verification failure" },
33
+ allowProtected: { type: "boolean", description: "Allow edits to protected files" },
34
+ },
35
+ },
36
+ },
37
+ {
38
+ name: "check_risk_tier",
39
+ description: "Classify changed files into Risk Tiers (R0 Cosmetic, R1 Routine, R2 Consequential, R3 Restricted).",
40
+ inputSchema: {
41
+ type: "object",
42
+ properties: {
43
+ files: {
44
+ type: "array",
45
+ items: { type: "string" },
46
+ description: "List of file paths changed",
47
+ },
48
+ diffLines: { type: "number", description: "Total lines changed in diff" },
49
+ },
50
+ required: ["files"],
51
+ },
52
+ },
53
+ {
54
+ name: "get_jules_status",
55
+ description: "Retrieve orchestrator status including daily task budget, active locks, and stack diagnostics.",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {},
59
+ },
60
+ },
61
+ ];
62
+
63
+ export async function handleMcpRequest(request, opts = {}) {
64
+ const root = opts.root || resolveRoot();
65
+ const config = opts.config || loadConfig(root);
66
+ const { id, method, params } = request;
67
+
68
+ if (method === "ping") {
69
+ return { jsonrpc: "2.0", id, result: {} };
70
+ }
71
+
72
+ if (method === "initialize") {
73
+ return {
74
+ jsonrpc: "2.0",
75
+ id,
76
+ result: {
77
+ protocolVersion: "2024-11-05",
78
+ capabilities: { tools: {} },
79
+ serverInfo: MCP_SERVER_INFO,
80
+ },
81
+ };
82
+ }
83
+
84
+ if (method === "notifications/initialized") {
85
+ return null; // Notifications produce no response
86
+ }
87
+
88
+ if (method === "tools/list") {
89
+ return {
90
+ jsonrpc: "2.0",
91
+ id,
92
+ result: { tools: MCP_TOOLS },
93
+ };
94
+ }
95
+
96
+ if (method === "tools/call") {
97
+ const toolName = params?.name;
98
+ const args = params?.arguments || {};
99
+
100
+ try {
101
+ if (toolName === "dispatch_jules_task") {
102
+ const session = await dispatch(
103
+ { title: args.title || "MCP Task Dispatch", prompt: args.prompt },
104
+ { root, config }
105
+ );
106
+ return {
107
+ jsonrpc: "2.0",
108
+ id,
109
+ result: {
110
+ content: [{ type: "text", text: JSON.stringify({ ok: true, session }, null, 2) }],
111
+ },
112
+ };
113
+ }
114
+
115
+ if (toolName === "audit_jules_gate") {
116
+ const res = await gate({
117
+ root,
118
+ config,
119
+ base: args.base || config.baseBranch || "main",
120
+ fix: args.fix || false,
121
+ allowProtected: args.allowProtected || false,
122
+ });
123
+ return {
124
+ jsonrpc: "2.0",
125
+ id,
126
+ result: {
127
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
128
+ },
129
+ };
130
+ }
131
+
132
+ if (toolName === "check_risk_tier") {
133
+ const tierResult = classifyRiskTier(args.files || [], { diffLines: args.diffLines || 0 });
134
+ return {
135
+ jsonrpc: "2.0",
136
+ id,
137
+ result: {
138
+ content: [{ type: "text", text: JSON.stringify(tierResult, null, 2) }],
139
+ },
140
+ };
141
+ }
142
+
143
+ if (toolName === "get_jules_status") {
144
+ const stackInfo = detectStack(root);
145
+ const budget = checkDailyBudget(root, config.limits.dailyTasks);
146
+ const locks = lockStatus(root);
147
+ const status = {
148
+ version: MCP_SERVER_INFO.version,
149
+ root,
150
+ stack: stackInfo.stack,
151
+ budget: { used: budget.used, limit: budget.budget, remaining: budget.budget - budget.used },
152
+ activeLocksCount: locks.length,
153
+ locks,
154
+ };
155
+ return {
156
+ jsonrpc: "2.0",
157
+ id,
158
+ result: {
159
+ content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
160
+ },
161
+ };
162
+ }
163
+
164
+ return {
165
+ jsonrpc: "2.0",
166
+ id,
167
+ error: { code: -32601, message: `Unknown tool: ${toolName}` },
168
+ };
169
+ } catch (err) {
170
+ return {
171
+ jsonrpc: "2.0",
172
+ id,
173
+ result: {
174
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: err.message }, null, 2) }],
175
+ isError: true,
176
+ },
177
+ };
178
+ }
179
+ }
180
+
181
+ return {
182
+ jsonrpc: "2.0",
183
+ id,
184
+ error: { code: -32601, message: `Method not found: ${method}` },
185
+ };
186
+ }
187
+
188
+ export function startMcpServer(input = process.stdin, output = process.stdout, opts = {}) {
189
+ const rl = createInterface({ input, output: null, terminal: false });
190
+
191
+ rl.on("line", async (line) => {
192
+ const trimmed = line.trim();
193
+ if (!trimmed) return;
194
+
195
+ try {
196
+ const request = JSON.parse(trimmed);
197
+ const response = await handleMcpRequest(request, opts);
198
+ if (response !== null) {
199
+ output.write(JSON.stringify(response) + "\n");
200
+ }
201
+ } catch (err) {
202
+ output.write(
203
+ JSON.stringify({
204
+ jsonrpc: "2.0",
205
+ id: null,
206
+ error: { code: -32700, message: `Parse error: ${err.message}` },
207
+ }) + "\n"
208
+ );
209
+ }
210
+ });
211
+ }
@@ -0,0 +1,179 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export const JULES_PRESET = {
4
+ name: "jules",
5
+ type: "http",
6
+ url: "https://jules.googleapis.com/v1/projects/{project}/sessions",
7
+ headers: {
8
+ "Authorization": "Bearer {token}",
9
+ "Content-Type": "application/json",
10
+ },
11
+ bodyTemplate: {
12
+ prompt: "{prompt}",
13
+ title: "{title}",
14
+ branch: "{branch}",
15
+ },
16
+ };
17
+
18
+ export const CLAUDE_PRESET = {
19
+ name: "claude-code",
20
+ type: "exec",
21
+ command: "claude",
22
+ args: ["--print", "-p", "{prompt}"],
23
+ promptViaStdin: true,
24
+ };
25
+
26
+ export const CODEX_PRESET = {
27
+ name: "codex",
28
+ type: "exec",
29
+ command: "codex",
30
+ args: ["exec", "{prompt}"],
31
+ promptViaStdin: false,
32
+ };
33
+
34
+ function interpolateString(template, data) {
35
+ if (typeof template !== "string") return template;
36
+ return template.replace(/\{(\w+)\}/g, (_, key) => data[key] ?? "");
37
+ }
38
+
39
+ function interpolateDeep(node, data) {
40
+ if (typeof node === "string") return interpolateString(node, data);
41
+ if (Array.isArray(node)) return node.map((item) => interpolateDeep(item, data));
42
+ if (node && typeof node === "object") {
43
+ const res = {};
44
+ for (const [k, v] of Object.entries(node)) {
45
+ res[k] = interpolateDeep(v, data);
46
+ }
47
+ return res;
48
+ }
49
+ return node;
50
+ }
51
+
52
+ /**
53
+ * Creates an HTTP or Exec provider adapter based on spec/config.
54
+ */
55
+ export function createProvider(spec = "jules", config = {}) {
56
+ const providerSpec = typeof spec === "string"
57
+ ? (spec === "claude-code" ? CLAUDE_PRESET : spec === "codex" ? CODEX_PRESET : JULES_PRESET)
58
+ : spec;
59
+
60
+ return {
61
+ name: providerSpec.name || "custom-provider",
62
+
63
+ async dispatch(task, ctx = {}) {
64
+ const rawToken = process.env.JULES_API_KEY || process.env.GEMINI_API_KEY || "";
65
+ const data = {
66
+ prompt: task.prompt || "",
67
+ title: task.title || "Agent Task",
68
+ branch: task.branch || (config.branchPrefix ? config.branchPrefix + "task" : "agent/task"),
69
+ project: process.env.JULES_PROJECT_ID || "default",
70
+ ...ctx,
71
+ };
72
+
73
+ if (ctx.dryRun) {
74
+ return {
75
+ id: "dry-run-session-id",
76
+ status: "pending",
77
+ title: data.title,
78
+ provider: providerSpec.name,
79
+ data: {
80
+ ...data,
81
+ token: rawToken ? "[REDACTED]" : undefined,
82
+ },
83
+ };
84
+ }
85
+
86
+ if (providerSpec.type === "http") {
87
+ const urlData = { ...data, token: rawToken };
88
+ const url = interpolateString(providerSpec.url, urlData);
89
+ const headers = {};
90
+ for (const [k, v] of Object.entries(providerSpec.headers || {})) {
91
+ const val = interpolateString(v, urlData);
92
+ if (val.includes("\r") || val.includes("\n")) {
93
+ throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
94
+ }
95
+ headers[k] = val;
96
+ }
97
+
98
+ let body;
99
+ if (typeof providerSpec.bodyTemplate === "string") {
100
+ try {
101
+ const parsedObj = JSON.parse(providerSpec.bodyTemplate);
102
+ const interpolatedObj = interpolateDeep(parsedObj, data);
103
+ body = JSON.stringify(interpolatedObj);
104
+ } catch (_) {
105
+ body = interpolateString(providerSpec.bodyTemplate, data);
106
+ }
107
+ } else if (providerSpec.bodyTemplate) {
108
+ const interpolatedObj = interpolateDeep(providerSpec.bodyTemplate, data);
109
+ body = JSON.stringify(interpolatedObj);
110
+ } else {
111
+ body = JSON.stringify(data);
112
+ }
113
+
114
+ const res = await fetch(url, {
115
+ method: providerSpec.method || "POST",
116
+ headers,
117
+ body,
118
+ });
119
+
120
+ if (!res.ok) {
121
+ const text = await res.text();
122
+ const cleanText = text.slice(0, 500);
123
+ const sanitizedText = rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText;
124
+ throw new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
125
+ }
126
+
127
+ const json = await res.json();
128
+ return {
129
+ id: json.id || json.name || "http-session",
130
+ status: json.state || "active",
131
+ raw: json,
132
+ };
133
+ }
134
+
135
+ if (providerSpec.type === "exec") {
136
+ const rawArgs = providerSpec.args || (providerSpec.command ? providerSpec.command.split(" ").slice(1) : []);
137
+ const command = providerSpec.command ? providerSpec.command.split(" ")[0] : "claude";
138
+
139
+ // CRITICAL H-a FIX: Filter out '{prompt}' argument if prompt is passed via stdin
140
+ const filteredArgs = providerSpec.promptViaStdin
141
+ ? rawArgs.filter((arg) => !arg.includes("{prompt}"))
142
+ : rawArgs;
143
+
144
+ const processedArgs = filteredArgs.map((arg) => interpolateString(arg, data));
145
+
146
+ const res = spawnSync(command, processedArgs, {
147
+ cwd: config._root || process.cwd(),
148
+ encoding: "utf-8",
149
+ shell: false, // CRITICAL: Ban shell execution to eliminate CWE-77
150
+ input: providerSpec.promptViaStdin ? data.prompt : undefined,
151
+ timeout: providerSpec.timeoutMs || 900000,
152
+ maxBuffer: 32 * 1024 * 1024,
153
+ });
154
+
155
+ if (res.error) {
156
+ throw new Error(`Provider Exec Failed: ${res.error.message}`);
157
+ }
158
+ if (res.status !== 0) {
159
+ const stderr = (res.stderr || "").slice(0, 500);
160
+ throw new Error(`Provider Exec Exit ${res.status}: ${stderr}`);
161
+ }
162
+
163
+ return {
164
+ id: "exec-session-" + Date.now(),
165
+ status: "completed",
166
+ output: res.stdout || "",
167
+ };
168
+ }
169
+
170
+ throw new Error(`Unsupported provider type: ${providerSpec.type}`);
171
+ },
172
+
173
+ validate() {
174
+ if (providerSpec.type === "http" && !providerSpec.url) return false;
175
+ if (providerSpec.type === "exec" && !providerSpec.command) return false;
176
+ return true;
177
+ },
178
+ };
179
+ }
package/src/risk.mjs ADDED
@@ -0,0 +1,120 @@
1
+ import { normalizePath } from "./config.mjs";
2
+
3
+ export const RISK_TIERS = {
4
+ R0: "R0_COSMETIC", // Docs, markdown, comments, safe devDep patches
5
+ R1: "R1_ROUTINE", // Pure utility logic, unit tests, single package layer
6
+ R2: "R2_CONSEQUENTIAL",// UI components, DB helpers, diff > 400 lines, bundle size impact
7
+ R3: "R3_RESTRICTED", // Migrations, Auth, Pricing/VAT, Protected paths (.github, configs)
8
+ };
9
+
10
+ const RESTRICTED_PATH_PATTERNS = [
11
+ "**/drizzle/migrations/**",
12
+ "**/migrations/**",
13
+ "**/auth/**",
14
+ "**/pricing/**",
15
+ "**/vat/**",
16
+ "**/contracts/**",
17
+ "**/ledger/**",
18
+ ".github/**",
19
+ ".githooks/**",
20
+ "wrangler.jsonc",
21
+ "pnpm-lock.yaml",
22
+ "package-lock.json",
23
+ ];
24
+
25
+ const CONSEQUENTIAL_PATH_PATTERNS = [
26
+ "apps/web/src/components/**",
27
+ "packages/db/**",
28
+ "src/engine.mjs",
29
+ "src/security.mjs",
30
+ ];
31
+
32
+ const COSMETIC_EXTENSIONS = new Set([".md", ".txt", ".jsonl", ".svg"]);
33
+
34
+ /**
35
+ * Classifies a set of changed files and diff metadata into a Risk Tier (R0, R1, R2, R3).
36
+ *
37
+ * @param {string[]} files - Array of changed file paths
38
+ * @param {Object} [opts] - Options (diffBytes, diffLines, author)
39
+ * @returns {{ tier: string, reason: string, isAutoMergeAllowed: boolean, requiresHumanReview: boolean }}
40
+ */
41
+ export function classifyRiskTier(files = [], opts = {}) {
42
+ const diffLines = opts.diffLines ?? 0;
43
+
44
+ if (!files || files.length === 0) {
45
+ return {
46
+ tier: RISK_TIERS.R0,
47
+ reason: "No files changed",
48
+ isAutoMergeAllowed: true,
49
+ requiresHumanReview: false,
50
+ };
51
+ }
52
+
53
+ // 1. Check R3 (Restricted Paths)
54
+ for (const rawFile of files) {
55
+ const file = normalizePath(rawFile);
56
+ for (const pat of RESTRICTED_PATH_PATTERNS) {
57
+ if (file.includes(pat.replace(/\*/g, "")) || file.endsWith(pat)) {
58
+ return {
59
+ tier: RISK_TIERS.R3,
60
+ reason: `Matches restricted security/financial path pattern '${pat}'`,
61
+ isAutoMergeAllowed: false,
62
+ requiresHumanReview: true,
63
+ };
64
+ }
65
+ }
66
+ }
67
+
68
+ // 2. Check R2 (Consequential Paths or Diff Size > 400 lines)
69
+ if (diffLines >= 400) {
70
+ return {
71
+ tier: RISK_TIERS.R2,
72
+ reason: `Diff size (${diffLines} lines) exceeds R1 limit of 400 lines`,
73
+ isAutoMergeAllowed: false,
74
+ requiresHumanReview: true,
75
+ };
76
+ }
77
+
78
+ for (const rawFile of files) {
79
+ const file = normalizePath(rawFile);
80
+ for (const pat of CONSEQUENTIAL_PATH_PATTERNS) {
81
+ if (file.includes(pat.replace(/\*/g, ""))) {
82
+ return {
83
+ tier: RISK_TIERS.R2,
84
+ reason: `Matches consequential component/DB path pattern '${pat}'`,
85
+ isAutoMergeAllowed: false,
86
+ requiresHumanReview: true,
87
+ };
88
+ }
89
+ }
90
+ }
91
+
92
+ // 3. Check R0 (Purely Cosmetic files)
93
+ const isAllCosmetic = files.every((rawFile) => {
94
+ const file = normalizePath(rawFile);
95
+ if (file.startsWith(".agent/history/") || file.startsWith("docs/")) return true;
96
+ const dotIdx = file.lastIndexOf(".");
97
+ if (dotIdx !== -1) {
98
+ const ext = file.slice(dotIdx).toLowerCase();
99
+ return COSMETIC_EXTENSIONS.has(ext);
100
+ }
101
+ return false;
102
+ });
103
+
104
+ if (isAllCosmetic) {
105
+ return {
106
+ tier: RISK_TIERS.R0,
107
+ reason: "All changed files are markdown, documentation, or cosmetic assets",
108
+ isAutoMergeAllowed: true,
109
+ requiresHumanReview: false,
110
+ };
111
+ }
112
+
113
+ // 4. Default R1 (Routine Logic)
114
+ return {
115
+ tier: RISK_TIERS.R1,
116
+ reason: "Routine isolated package logic / unit test update within limits",
117
+ isAutoMergeAllowed: true,
118
+ requiresHumanReview: false,
119
+ };
120
+ }