jules-orchestrator-kit 0.9.4 → 0.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.
package/src/config.mjs CHANGED
@@ -278,6 +278,30 @@ export function resolveVerify(root = process.cwd()) {
278
278
  return { test: s.testCmd || "", build: s.buildCmd || "" };
279
279
  }
280
280
 
281
+ export const TIER_PRESETS = {
282
+ free: {
283
+ dailyTasks: 15,
284
+ repairAttempts: 1,
285
+ concurrency: 1,
286
+ staggerMs: 3000,
287
+ diffKb: 50,
288
+ },
289
+ pro: {
290
+ dailyTasks: 100,
291
+ repairAttempts: 2,
292
+ concurrency: 2,
293
+ staggerMs: 1500,
294
+ diffKb: 75,
295
+ },
296
+ ultra: {
297
+ dailyTasks: 300,
298
+ repairAttempts: 3,
299
+ concurrency: 3,
300
+ staggerMs: 1000,
301
+ diffKb: 75,
302
+ },
303
+ };
304
+
281
305
  /**
282
306
  * Loads and validates configuration from .agent/config.yml or .agent/jules.yml.
283
307
  */
@@ -301,9 +325,16 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
301
325
  const buildCmd = parsed.build_cmd || parsed.verify?.build || "";
302
326
  const autoVerify = resolveVerify(root);
303
327
 
328
+ const activeTier = String(process.env.JULES_TIER || parsed.tier || "ultra").toLowerCase();
329
+ const tierLimits = TIER_PRESETS[activeTier] || TIER_PRESETS.ultra;
330
+
331
+ const envDailyTasks = process.env.JULES_DAILY_BUDGET ? Number(process.env.JULES_DAILY_BUDGET) : null;
332
+ const envDiffKb = process.env.JULES_MAX_DIFF_KB ? Number(process.env.JULES_MAX_DIFF_KB) : null;
333
+
304
334
  const config = {
305
335
  version: parsed.version || DEFAULTS.version,
306
336
  provider: parsed.provider || DEFAULTS.provider,
337
+ tier: activeTier,
307
338
  verify: {
308
339
  test: testCmd || autoVerify.test,
309
340
  build: buildCmd || autoVerify.build,
@@ -311,7 +342,10 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
311
342
  scope: normalizeScope(parsed),
312
343
  limits: {
313
344
  ...DEFAULTS.limits,
345
+ ...tierLimits,
314
346
  ...(parsed.limits || {}),
347
+ ...(envDailyTasks && !isNaN(envDailyTasks) ? { dailyTasks: envDailyTasks } : {}),
348
+ ...(envDiffKb && !isNaN(envDiffKb) ? { diffKb: envDiffKb } : {}),
315
349
  },
316
350
  isolation: parsed.isolation || DEFAULTS.isolation,
317
351
  runner: parsed.runner || DEFAULTS.runner,
package/src/engine.mjs CHANGED
@@ -5,6 +5,33 @@ import { createProvider } from "./provider.mjs";
5
5
  import { withBudget, appendLedger, getQueueDir, ensureDir } from "./state.mjs";
6
6
  import { readdirSync, readFileSync, renameSync } from "node:fs";
7
7
  import { join } from "node:path";
8
+ import { createHash } from "node:crypto";
9
+
10
+ /**
11
+ * Computes a SHA-256 fingerprint for a failure state (normalized stderr + diff text).
12
+ */
13
+ export function fingerprintFailureState(failure = {}, root = process.cwd()) {
14
+ const rawStderr = failure.stderr || failure.stdout || failure.message || "Unknown Error";
15
+ const normalizedStderr = String(rawStderr)
16
+ .replace(/:\d+:\d+/g, ":?:?")
17
+ .replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z?\b/g, "<timestamp>")
18
+ .replace(/\b[0-9a-f]{7,40}\b/g, "<sha>")
19
+ .replace(/\/[\w\/-]+\//g, "/?/")
20
+ .replace(/\s+/g, " ")
21
+ .trim();
22
+
23
+ let diffStat = "0:0";
24
+ try {
25
+ const rawDiff = diffText(root) || "";
26
+ const lines = rawDiff.split("\n");
27
+ const added = lines.filter((l) => l.startsWith("+")).length;
28
+ const removed = lines.filter((l) => l.startsWith("-")).length;
29
+ diffStat = `${added}:${removed}`;
30
+ } catch (_) {}
31
+
32
+ const combined = `${normalizedStderr}::${diffStat}`;
33
+ return createHash("sha256").update(combined).digest("hex").substring(0, 16);
34
+ }
8
35
 
9
36
  /**
10
37
  * Gatekeeper verification engine (Phase 1: Scope, Phase 2: Payload, Phase 3: Secrets, Phase 4: Verify Commands).
@@ -106,14 +133,77 @@ export async function gate(opts = {}) {
106
133
  return { ok: verifyOk, code: verifyOk ? 0 : 4, phases };
107
134
  }
108
135
 
136
+ /**
137
+ * Sliding-window ring-buffer circuit breaker for non-convergent OODA loops.
138
+ */
139
+ export class OODACircuitBreaker {
140
+ constructor({ windowSize = 6, threshold = 2, cooldownMs = 60000 } = {}) {
141
+ this.windowSize = windowSize;
142
+ this.threshold = threshold;
143
+ this.cooldownMs = cooldownMs;
144
+ this.ring = [];
145
+ this.openedAt = 0;
146
+ this.openedFingerprint = null;
147
+ }
148
+
149
+ observe(fingerprint) {
150
+ if (!fingerprint || typeof fingerprint !== "string") {
151
+ return { tripped: false };
152
+ }
153
+ if (this.isOpen()) {
154
+ return { tripped: true, reason: "OODA_CIRCUIT_OPEN", fingerprint: this.openedFingerprint };
155
+ }
156
+ const occurrences = this.ring.filter((f) => f === fingerprint).length + 1;
157
+ this.ring.push(fingerprint);
158
+ if (this.ring.length > this.windowSize) this.ring.shift();
159
+
160
+ if (occurrences >= this.threshold) {
161
+ this.openedAt = Date.now();
162
+ this.openedFingerprint = fingerprint;
163
+ return {
164
+ tripped: true,
165
+ reason: "OODA_THRASH_DETECTED",
166
+ fingerprint,
167
+ occurrences,
168
+ };
169
+ }
170
+ return { tripped: false, occurrences };
171
+ }
172
+
173
+ isOpen() {
174
+ if (this.openedAt === 0) return false;
175
+ if (Date.now() - this.openedAt >= this.cooldownMs) {
176
+ this.openedAt = 0;
177
+ this.openedFingerprint = null;
178
+ this.ring = [];
179
+ return false;
180
+ }
181
+ return true;
182
+ }
183
+
184
+ reset() {
185
+ this.ring = [];
186
+ this.openedAt = 0;
187
+ this.openedFingerprint = null;
188
+ }
189
+ }
190
+
109
191
  export async function repair(failure, opts = {}) {
110
192
  const root = opts.root || process.cwd();
111
193
  const config = opts.config || loadConfig(root);
112
194
  const maxRetries = config.limits.repairAttempts || 3;
113
- const provider = createProvider(config.provider, config);
195
+ const provider = opts.provider || createProvider(config.provider, config);
114
196
  const attempts = [];
197
+ const breaker = opts.circuitBreaker || new OODACircuitBreaker({
198
+ windowSize: Math.max(maxRetries + 1, 6),
199
+ threshold: 2,
200
+ cooldownMs: 60000,
201
+ });
115
202
 
116
203
  let currentFailure = failure;
204
+ const initialFingerprint = fingerprintFailureState(currentFailure, root);
205
+ breaker.observe(initialFingerprint);
206
+
117
207
  for (let n = 1; n <= maxRetries; n++) {
118
208
  const repairPrompt = buildRepairPrompt(currentFailure, n, config);
119
209
  let session;
@@ -137,9 +227,23 @@ export async function repair(failure, opts = {}) {
137
227
  // Re-verify after repair attempt
138
228
  const gateRes = await gate({ root, config, fix: false });
139
229
  if (gateRes.ok) {
230
+ breaker.reset();
140
231
  return { ok: true, attempts, finalStatus: "PASSED" };
141
232
  }
233
+
142
234
  currentFailure = gateRes.phases.find((p) => p.phase === "verify")?.testResult || failure;
235
+ const currentFingerprint = fingerprintFailureState(currentFailure, root);
236
+
237
+ const check = breaker.observe(currentFingerprint);
238
+ if (check.tripped) {
239
+ return {
240
+ ok: false,
241
+ attempts,
242
+ finalStatus: "DETERMINISTIC_REGRESSION",
243
+ reason: `Identical failure state fingerprint (${currentFingerprint}) observed during attempt #${n}`,
244
+ fingerprint: currentFingerprint,
245
+ };
246
+ }
143
247
  }
144
248
 
145
249
  return { ok: false, attempts, finalStatus: "OODA_EXHAUSTED" };
@@ -147,11 +251,23 @@ export async function repair(failure, opts = {}) {
147
251
 
148
252
  function buildRepairPrompt(failure, attempt, _config) {
149
253
  const cleanStderr = redactSecrets(failure.stderr || failure.stdout || "Unknown Error");
150
- return `Auto-Repair Attempt #${attempt}
254
+
255
+ let escalationStrategy = "DIRECT_REPAIR";
256
+ let escalationDirective = "1. Ground your fix strictly in the error log below. Do NOT guess file contents or function signatures.";
257
+
258
+ if (attempt === 2) {
259
+ escalationStrategy = "DIAGNOSTIC_ANALYSIS";
260
+ escalationDirective = "1. DIAGNOSE FIRST: Two attempts have failed. Analyze if the root cause is in a different file or component than where the previous patch was applied.";
261
+ } else if (attempt >= 3) {
262
+ escalationStrategy = "MINIMAL_SIMPLIFICATION";
263
+ escalationDirective = "1. SIMPLIFY: Multiple repair attempts have failed. Revert complex refactors and implement the minimal patch that satisfies the failing assertion.";
264
+ }
265
+
266
+ return `Auto-Repair Attempt #${attempt} [Strategy: ${escalationStrategy}]
151
267
  Command Failed: ${failure.command || "verify"}
152
268
 
153
269
  REPAIR DIRECTIVE & STRICT INVARIANTS:
154
- 1. Ground your fix strictly in the error log below. Do NOT guess file contents or function signatures.
270
+ ${escalationDirective}
155
271
  2. NO WEAKENING: You are STRICTLY FORBIDDEN from deleting tests, commenting out assertions, or weakening expectations to achieve a passing test. Leave unmet requirements RED if the requirement is valid and feature code is missing.
156
272
  3. CARRY EVIDENCE: Address the root cause directly and verify the exact fix.
157
273
  4. RE-VERIFY: Execute full verification after patching to ensure zero new regressions.
@@ -0,0 +1,103 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolveRoot } from "./config.mjs";
3
+ import { resolveBase } from "./git.mjs";
4
+ import { classifyRiskTier } from "./risk.mjs";
5
+
6
+ /**
7
+ * Generates a SHA-256 hash of the execution envelope payload.
8
+ */
9
+ export function hashExecutionEnvelope(envelope) {
10
+ if (!envelope || typeof envelope !== "object") return "";
11
+ const rawPayload = {
12
+ id: envelope.id,
13
+ taskId: envelope.taskId,
14
+ baseRef: envelope.baseRef,
15
+ baseSha: envelope.baseSha,
16
+ configSha: envelope.configSha,
17
+ scope: envelope.scope,
18
+ verify: envelope.verify,
19
+ riskTier: envelope.riskTier,
20
+ budgetReservationId: envelope.budgetReservationId,
21
+ capabilities: envelope.capabilities,
22
+ createdAt: envelope.createdAt,
23
+ };
24
+ const sortedKeys = Object.keys(rawPayload).sort();
25
+ const canonicalPayload = {};
26
+ for (const k of sortedKeys) {
27
+ if (rawPayload[k] !== undefined) {
28
+ canonicalPayload[k] = rawPayload[k];
29
+ }
30
+ }
31
+ return createHash("sha256").update(JSON.stringify(canonicalPayload)).digest("hex");
32
+ }
33
+
34
+ /**
35
+ * Deep freezes an execution envelope to prevent modifications during OODA repair loops.
36
+ */
37
+ export function freezeExecutionEnvelope(envelope) {
38
+ if (!envelope || typeof envelope !== "object") return envelope;
39
+ return Object.freeze({
40
+ ...envelope,
41
+ scope: Object.freeze({
42
+ deny: Object.freeze([...(envelope.scope?.deny || [])]),
43
+ allow: Object.freeze([...(envelope.scope?.allow || [])]),
44
+ protect: Object.freeze([...(envelope.scope?.protect || [])]),
45
+ }),
46
+ verify: Object.freeze({ ...(envelope.verify || {}) }),
47
+ capabilities: Object.freeze([...(envelope.capabilities || [])]),
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Creates an immutable Capability-Bounded Execution Envelope (CBEE).
53
+ */
54
+ export function createExecutionEnvelope(task = {}, opts = {}) {
55
+ const root = opts.root || resolveRoot();
56
+ const baseRef = opts.base || task.base || "main";
57
+ const baseSha = resolveBase(root, baseRef);
58
+
59
+ const taskId = task.id || opts.taskId || `task-${Date.now()}`;
60
+ const envelopeId = `env-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
61
+
62
+ const denyPaths = opts.forbiddenPaths || task.forbiddenPaths || [".github/**", "package-lock.json", "pnpm-lock.yaml"];
63
+ const allowPaths = opts.allowPaths || task.allowPaths || [];
64
+ const protectPaths = opts.protectPaths || task.protectPaths || ["package.json", "tsconfig.json", "AGENTS.md"];
65
+
66
+ const files = task.files || opts.files || [];
67
+ const riskMeta = classifyRiskTier(files, { diffLines: opts.diffLines || 0 });
68
+
69
+ const rawEnvelope = {
70
+ id: envelopeId,
71
+ taskId,
72
+ baseRef,
73
+ baseSha,
74
+ configSha: createHash("sha256").update(JSON.stringify({ denyPaths, allowPaths, protectPaths })).digest("hex"),
75
+ scope: {
76
+ deny: denyPaths,
77
+ allow: allowPaths,
78
+ protect: protectPaths,
79
+ },
80
+ verify: {
81
+ testCmd: opts.testCmd || task.testCmd || "npm test",
82
+ buildCmd: opts.buildCmd || task.buildCmd || "npm run build",
83
+ },
84
+ riskTier: riskMeta.tier,
85
+ budgetReservationId: opts.reservationId || null,
86
+ capabilities: opts.capabilities || ["git:read", "git:diff", "test:run"],
87
+ createdAt: new Date().toISOString(),
88
+ };
89
+
90
+ const envelopeHash = hashExecutionEnvelope(rawEnvelope);
91
+ const fullEnvelope = { ...rawEnvelope, hash: envelopeHash };
92
+
93
+ return freezeExecutionEnvelope(fullEnvelope);
94
+ }
95
+
96
+ /**
97
+ * Validates that an envelope has not been tampered with.
98
+ */
99
+ export function verifyExecutionEnvelope(envelope) {
100
+ if (!envelope || !envelope.hash) return false;
101
+ const expectedHash = hashExecutionEnvelope(envelope);
102
+ return envelope.hash === expectedHash;
103
+ }
package/src/git.mjs CHANGED
@@ -16,10 +16,28 @@ function normalizePath(p) {
16
16
 
17
17
  export function runCmd(command, opts = {}) {
18
18
  const cwd = opts.cwd || process.cwd();
19
+ let binary = "";
20
+ let args = [];
21
+
22
+ if (Array.isArray(command)) {
23
+ binary = command[0];
24
+ args = command.slice(1);
25
+ } else if (typeof command === "string") {
26
+ const tokens = command.trim().split(/\s+/).filter(Boolean);
27
+ binary = tokens[0] || "";
28
+ args = tokens.slice(1);
29
+ }
30
+
31
+ if (!binary) {
32
+ if (opts.ignoreError) return { status: 0, stdout: "", stderr: "" };
33
+ throw new GateError("Empty command provided");
34
+ }
35
+
19
36
  try {
20
- const stdout = execFileSync("sh", ["-c", command], {
37
+ const stdout = execFileSync(binary, args, {
21
38
  cwd,
22
39
  encoding: "utf-8",
40
+ shell: false,
23
41
  stdio: ["ignore", "pipe", "pipe"],
24
42
  });
25
43
  return { status: 0, stdout: stdout.trim(), stderr: "" };
@@ -59,7 +77,7 @@ export function git(args = [], opts = {}) {
59
77
  }
60
78
 
61
79
  export function resolveBase(root = process.cwd(), baseRef = "main") {
62
- const candidates = [`origin/${baseRef}`, baseRef, `refs/remotes/origin/${baseRef}`, "HEAD"];
80
+ const candidates = [`origin/${baseRef}`, `refs/remotes/origin/${baseRef}`, baseRef];
63
81
  for (const ref of candidates) {
64
82
  try {
65
83
  const res = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
package/src/mcp.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { createInterface } from "node:readline";
1
+ import { Transform } from "node:stream";
2
2
  import { loadConfig, resolveRoot, detectStack } from "./config.mjs";
3
3
  import { gate, dispatch } from "./engine.mjs";
4
4
  import { classifyRiskTier } from "./risk.mjs";
@@ -6,9 +6,86 @@ import { checkDailyBudget, lockStatus } from "./state.mjs";
6
6
 
7
7
  export const MCP_SERVER_INFO = {
8
8
  name: "jules-orchestrator-kit",
9
- version: "0.9.4",
9
+ version: "0.20.0",
10
10
  };
11
11
 
12
+ export const MAX_MCP_FRAME_SIZE = 4 * 1024 * 1024; // 4 MB memory safety ceiling
13
+
14
+ /**
15
+ * Memory-bounded MCP Frame Decoder supporting both Content-Length headers and line-delimited JSON-RPC messages.
16
+ */
17
+ export class McpFrameDecoder extends Transform {
18
+ constructor(options = {}) {
19
+ super({ ...options, readableObjectMode: true });
20
+ this.buffer = Buffer.alloc(0);
21
+ this.maxFrameSize = options.maxFrameSize || MAX_MCP_FRAME_SIZE;
22
+ }
23
+
24
+ _transform(chunk, encoding, callback) {
25
+ this.buffer = Buffer.concat([this.buffer, chunk]);
26
+
27
+ if (this.buffer.length > this.maxFrameSize * 2) {
28
+ this.emit("error", new Error(`MCP Stdio buffer exceeded max limit of ${this.maxFrameSize} bytes`));
29
+ this.buffer = Buffer.alloc(0);
30
+ return callback();
31
+ }
32
+
33
+ while (this.buffer.length > 0) {
34
+ const str = this.buffer.toString("utf-8");
35
+ const headerMatch = /Content-Length:\s*(\d+)\r?\n\r?\n/i.exec(str);
36
+
37
+ if (headerMatch) {
38
+ const contentLength = parseInt(headerMatch[1], 10);
39
+ if (contentLength > this.maxFrameSize) {
40
+ this.emit("error", new Error(`Frame Content-Length (${contentLength}) exceeds limit (${this.maxFrameSize})`));
41
+ this.buffer = Buffer.alloc(0);
42
+ return callback();
43
+ }
44
+
45
+ const headerLength = headerMatch.index + headerMatch[0].length;
46
+ const totalFrameLength = headerLength + contentLength;
47
+
48
+ if (this.buffer.length >= totalFrameLength) {
49
+ const payloadBuf = this.buffer.subarray(headerLength, totalFrameLength);
50
+ this.buffer = this.buffer.subarray(totalFrameLength);
51
+ try {
52
+ const parsed = JSON.parse(payloadBuf.toString("utf-8"));
53
+ this.push(parsed);
54
+ } catch (err) {
55
+ this.emit("error", new Error(`Malformed JSON payload in framed message: ${err.message}`));
56
+ }
57
+ continue;
58
+ } else {
59
+ break;
60
+ }
61
+ }
62
+
63
+ const newlineIndex = this.buffer.indexOf(0x0a);
64
+ if (newlineIndex !== -1) {
65
+ const lineBuf = this.buffer.subarray(0, newlineIndex);
66
+ this.buffer = this.buffer.subarray(newlineIndex + 1);
67
+
68
+ const lineStr = lineBuf.toString("utf-8").trim();
69
+ if (!lineStr) continue;
70
+
71
+ if (/^Content-Length:\s*\d+/i.test(lineStr)) continue;
72
+
73
+ try {
74
+ const parsed = JSON.parse(lineStr);
75
+ this.push(parsed);
76
+ } catch (err) {
77
+ this.emit("error", new Error(`Malformed JSON in line-delimited message: ${err.message}`));
78
+ }
79
+ continue;
80
+ }
81
+
82
+ break;
83
+ }
84
+
85
+ callback();
86
+ }
87
+ }
88
+
12
89
  export const MCP_TOOLS = [
13
90
  {
14
91
  name: "dispatch_jules_task",
@@ -82,7 +159,7 @@ export async function handleMcpRequest(request, opts = {}) {
82
159
  }
83
160
 
84
161
  if (method === "notifications/initialized") {
85
- return null; // Notifications produce no response
162
+ return null;
86
163
  }
87
164
 
88
165
  if (method === "tools/list") {
@@ -186,26 +263,35 @@ export async function handleMcpRequest(request, opts = {}) {
186
263
  }
187
264
 
188
265
  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;
266
+ const decoder = new McpFrameDecoder({ maxFrameSize: opts.maxFrameSize || MAX_MCP_FRAME_SIZE });
194
267
 
268
+ decoder.on("data", async (request) => {
195
269
  try {
196
- const request = JSON.parse(trimmed);
197
270
  const response = await handleMcpRequest(request, opts);
198
- if (response !== null) {
271
+ if (response !== null && response !== undefined) {
199
272
  output.write(JSON.stringify(response) + "\n");
200
273
  }
201
274
  } catch (err) {
275
+ // Panic boundary: Catch unhandled async exceptions and send valid JSON-RPC error
202
276
  output.write(
203
277
  JSON.stringify({
204
278
  jsonrpc: "2.0",
205
- id: null,
206
- error: { code: -32700, message: `Parse error: ${err.message}` },
279
+ id: request?.id || null,
280
+ error: { code: -32603, message: `Internal server panic: ${err.message}` },
207
281
  }) + "\n"
208
282
  );
209
283
  }
210
284
  });
285
+
286
+ decoder.on("error", (err) => {
287
+ output.write(
288
+ JSON.stringify({
289
+ jsonrpc: "2.0",
290
+ id: null,
291
+ error: { code: -32700, message: `MCP Stream framing error: ${err.message}` },
292
+ }) + "\n"
293
+ );
294
+ });
295
+
296
+ input.pipe(decoder);
211
297
  }
@@ -0,0 +1,80 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * ProcessGroupManager manages child processes in isolated process groups
5
+ * and ensures 100% leak-free cleanup on process exit or termination signals.
6
+ */
7
+ export class ProcessGroupManager {
8
+ constructor() {
9
+ this.activePgids = new Set();
10
+ this.activePids = new Set();
11
+ this.installed = false;
12
+ this.installSignalHandlers();
13
+ }
14
+
15
+ installSignalHandlers() {
16
+ if (this.installed) return;
17
+ this.installed = true;
18
+
19
+ const cleanup = (signal) => {
20
+ this.killAll(signal || "SIGTERM");
21
+ };
22
+
23
+ process.once("SIGINT", () => {
24
+ cleanup("SIGINT");
25
+ process.exit(130);
26
+ });
27
+
28
+ process.once("SIGTERM", () => {
29
+ cleanup("SIGTERM");
30
+ process.exit(143);
31
+ });
32
+
33
+ process.once("exit", () => {
34
+ this.killAll("SIGKILL");
35
+ });
36
+ }
37
+
38
+ spawnProcess(command, args = [], options = {}) {
39
+ const opts = {
40
+ ...options,
41
+ detached: true,
42
+ };
43
+
44
+ const child = spawn(command, args, opts);
45
+
46
+ if (child.pid) {
47
+ const pgid = child.pid;
48
+ this.activePgids.add(pgid);
49
+ this.activePids.add(child.pid);
50
+
51
+ child.on("exit", () => {
52
+ this.activePgids.delete(pgid);
53
+ this.activePids.delete(child.pid);
54
+ });
55
+
56
+ child.on("error", () => {
57
+ this.activePgids.delete(pgid);
58
+ this.activePids.delete(child.pid);
59
+ });
60
+ }
61
+
62
+ return child;
63
+ }
64
+
65
+ killAll(signal = "SIGTERM") {
66
+ for (const pgid of this.activePgids) {
67
+ try {
68
+ process.kill(-pgid, signal);
69
+ } catch (_) {
70
+ try {
71
+ process.kill(pgid, signal);
72
+ } catch (_) {}
73
+ }
74
+ }
75
+ this.activePgids.clear();
76
+ this.activePids.clear();
77
+ }
78
+ }
79
+
80
+ export const processGroupManager = new ProcessGroupManager();
package/src/risk.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { normalizePath } from "./config.mjs";
2
+ import { matchesGlob } from "./security.mjs";
2
3
 
3
4
  export const RISK_TIERS = {
4
5
  R0: "R0_COSMETIC", // Docs, markdown, comments, safe devDep patches
@@ -54,7 +55,7 @@ export function classifyRiskTier(files = [], opts = {}) {
54
55
  for (const rawFile of files) {
55
56
  const file = normalizePath(rawFile);
56
57
  for (const pat of RESTRICTED_PATH_PATTERNS) {
57
- if (file.includes(pat.replace(/\*/g, "")) || file.endsWith(pat)) {
58
+ if (matchesGlob(file, pat) || file.endsWith(pat)) {
58
59
  return {
59
60
  tier: RISK_TIERS.R3,
60
61
  reason: `Matches restricted security/financial path pattern '${pat}'`,
@@ -78,7 +79,7 @@ export function classifyRiskTier(files = [], opts = {}) {
78
79
  for (const rawFile of files) {
79
80
  const file = normalizePath(rawFile);
80
81
  for (const pat of CONSEQUENTIAL_PATH_PATTERNS) {
81
- if (file.includes(pat.replace(/\*/g, ""))) {
82
+ if (matchesGlob(file, pat)) {
82
83
  return {
83
84
  tier: RISK_TIERS.R2,
84
85
  reason: `Matches consequential component/DB path pattern '${pat}'`,