jules-orchestrator-kit 0.32.4 → 0.32.5

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/router.mjs ADDED
@@ -0,0 +1,200 @@
1
+ import { normalizePath } from "./config.mjs";
2
+ import { matchesGlob } from "./security.mjs";
3
+ import { extractPathTokens } from "./task-optimizer.mjs";
4
+ import { createProvider, createFailoverProvider } from "./provider.mjs";
5
+
6
+ /**
7
+ * Dynamic Complexity & Cost Router (Roadmap v0.33.0).
8
+ *
9
+ * Zero-dependency, rule-based heuristic classifier — no ML classifier, no
10
+ * external routing service. Routes trivial tasks (typos, lockfile bumps,
11
+ * lint fixes) to a cheap/fast provider while reserving the primary provider
12
+ * for complex, multi-file, or safety-sensitive work. Opt-in via
13
+ * `config.router.enabled` — disabled by default, zero behavior change for
14
+ * existing users.
15
+ */
16
+
17
+ export const ROUTE_TIERS = {
18
+ FAST: "fast",
19
+ COMPLEX: "complex",
20
+ };
21
+
22
+ const TRIVIAL_SIGNALS = [
23
+ /\btypo(s)?\b/i,
24
+ /\brenam(e|ing|ed)\b/i,
25
+ /\bbump(ed|ing)?\b/i,
26
+ /\bversion bump\b/i,
27
+ /\block ?file\b/i,
28
+ /\bformat(ting)?\b/i,
29
+ /\blint(ing)?\s*(fix(es)?|error)?\b/i,
30
+ /\bwhitespace\b/i,
31
+ /\b(update|fix)\s+comment(s)?\b/i,
32
+ /\bdead code\b/i,
33
+ /\bunused (import|variable)s?\b/i,
34
+ /\breadme\b/i,
35
+ /\btypo fix\b/i,
36
+ ];
37
+
38
+ const COMPLEX_SIGNALS = [
39
+ /\brefactor(ing)?\b/i,
40
+ /\barchitect(ure)?\b/i,
41
+ /\bmigrat(e|ion|ing)\b/i,
42
+ /\bredesign\b/i,
43
+ /\bconcurrency\b/i,
44
+ /\brace condition\b/i,
45
+ /\bsecurity\b/i,
46
+ /\bauth(entication|orization)?\b/i,
47
+ /\bschema\b/i,
48
+ /\bbreaking change\b/i,
49
+ /\bmulti-file\b/i,
50
+ /\bcross-package\b/i,
51
+ /\bdatabase\b/i,
52
+ /\bpayment(s)?\b/i,
53
+ /\bencrypt(ion)?\b/i,
54
+ ];
55
+
56
+ // Sentinel handles security-sensitive work; the primary provider is always used.
57
+ const FORCE_COMPLEX_ROLES = new Set(["sentinel"]);
58
+ const FAST_LEANING_ROLES = new Set(["janitor", "bolt"]);
59
+ const COMPLEX_LEANING_ROLES = new Set(["overseer", "sentinel"]);
60
+
61
+ // Supplements config.scope.deny — these are never eligible for the fast tier
62
+ // regardless of user scope config, mirroring src/risk.mjs's RESTRICTED_PATH_PATTERNS.
63
+ const SENSITIVE_PATH_PATTERNS = [
64
+ "**/auth/**",
65
+ "**/migrations/**",
66
+ "**/pricing/**",
67
+ "**/secrets/**",
68
+ "**/*.pem",
69
+ "**/*.key",
70
+ ".github/**",
71
+ ".agent/config.yml",
72
+ ".agent/jules.yml",
73
+ ];
74
+
75
+ function collectReferencedPaths(task) {
76
+ const fromPrompt = extractPathTokens(task.prompt || "");
77
+ const explicit = Array.isArray(task.targetFiles)
78
+ ? task.targetFiles
79
+ : Array.isArray(task.referenced_paths)
80
+ ? task.referenced_paths
81
+ : [];
82
+ return [...new Set([...fromPrompt, ...explicit])];
83
+ }
84
+
85
+ function touchesSensitivePath(paths, config) {
86
+ const denyPatterns = [...SENSITIVE_PATH_PATTERNS, ...((config && config.scope && config.scope.deny) || [])];
87
+ for (const rawPath of paths) {
88
+ const p = normalizePath(rawPath);
89
+ for (const pattern of denyPatterns) {
90
+ if (matchesGlob(p, pattern)) return { path: p, pattern };
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Heuristically classifies a task envelope into a FAST or COMPLEX routing tier.
98
+ * @param {object} task - Task envelope (title, prompt, role, targetFiles, tier).
99
+ * @param {object} config - Loaded orchestrator config (uses scope.deny, router.threshold).
100
+ * @returns {{tier: string, score: number|null, forced: boolean, reason: string, signals?: string[]}}
101
+ */
102
+ export function classifyTaskComplexity(task = {}, config = {}) {
103
+ const explicitTier = task.tier || task.complexity;
104
+ if (explicitTier === ROUTE_TIERS.FAST || explicitTier === ROUTE_TIERS.COMPLEX) {
105
+ return { tier: explicitTier, score: null, forced: true, reason: `Explicit task tier override: '${explicitTier}'` };
106
+ }
107
+
108
+ const paths = collectReferencedPaths(task);
109
+ const sensitiveHit = touchesSensitivePath(paths, config);
110
+ if (sensitiveHit) {
111
+ return {
112
+ tier: ROUTE_TIERS.COMPLEX,
113
+ score: null,
114
+ forced: true,
115
+ reason: `Touches sensitive path '${sensitiveHit.path}' (matches '${sensitiveHit.pattern}')`,
116
+ };
117
+ }
118
+
119
+ const role = String(task.role || "").toLowerCase();
120
+ if (FORCE_COMPLEX_ROLES.has(role)) {
121
+ return { tier: ROUTE_TIERS.COMPLEX, score: null, forced: true, reason: `Role '${role}' always routes to the primary provider` };
122
+ }
123
+
124
+ const text = `${task.title || ""} ${task.prompt || ""}`;
125
+ let score = 0;
126
+ const signals = [];
127
+
128
+ for (const pattern of COMPLEX_SIGNALS) {
129
+ if (pattern.test(text)) {
130
+ score += 2;
131
+ signals.push(`+2 complex keyword (${pattern.source})`);
132
+ }
133
+ }
134
+ for (const pattern of TRIVIAL_SIGNALS) {
135
+ if (pattern.test(text)) {
136
+ score -= 1;
137
+ signals.push(`-1 trivial keyword (${pattern.source})`);
138
+ }
139
+ }
140
+
141
+ if (paths.length >= 4) {
142
+ score += 3;
143
+ signals.push(`+3 touches ${paths.length} files`);
144
+ } else if (paths.length > 0 && paths.length <= 1) {
145
+ score -= 1;
146
+ signals.push(`-1 touches a single file`);
147
+ }
148
+
149
+ const promptLen = (task.prompt || "").length;
150
+ if (promptLen > 1200) {
151
+ score += 1;
152
+ signals.push(`+1 long prompt (${promptLen} chars)`);
153
+ } else if (promptLen > 0 && promptLen < 200) {
154
+ score -= 1;
155
+ signals.push(`-1 short prompt (${promptLen} chars)`);
156
+ }
157
+
158
+ if (COMPLEX_LEANING_ROLES.has(role)) {
159
+ score += 1;
160
+ signals.push(`+1 role '${role}' leans complex`);
161
+ }
162
+ if (FAST_LEANING_ROLES.has(role)) {
163
+ score -= 2;
164
+ signals.push(`-2 role '${role}' leans fast`);
165
+ }
166
+
167
+ const threshold = Number.isFinite(config?.router?.threshold) ? config.router.threshold : 0;
168
+ const tier = score > threshold ? ROUTE_TIERS.COMPLEX : ROUTE_TIERS.FAST;
169
+
170
+ return { tier, score, forced: false, threshold, signals, reason: `Heuristic score ${score} vs threshold ${threshold}` };
171
+ }
172
+
173
+ /**
174
+ * Resolves the provider adapter to dispatch a task through, honoring
175
+ * `config.router` (opt-in Dynamic Complexity & Cost Router). Disabled by
176
+ * default — returns the primary provider unchanged. FAST-tier tasks are
177
+ * wrapped in a failover cascade so a rate-limited/unavailable fast provider
178
+ * falls through to the primary provider automatically.
179
+ * @param {object} task
180
+ * @param {object} config
181
+ * @returns {{provider: object, routed: boolean, classification: object|null}}
182
+ */
183
+ export function resolveRoutedProvider(task = {}, config = {}) {
184
+ const routerCfg = config.router || {};
185
+ const complexSpec = routerCfg.complex || config.provider || "jules";
186
+
187
+ if (!routerCfg.enabled) {
188
+ return { provider: createProvider(config.provider, config), routed: false, classification: null };
189
+ }
190
+
191
+ const classification = classifyTaskComplexity(task, config);
192
+
193
+ if (classification.tier === ROUTE_TIERS.COMPLEX) {
194
+ return { provider: createProvider(complexSpec, config), routed: true, classification };
195
+ }
196
+
197
+ const fastSpec = routerCfg.fast || "gemini-flash";
198
+ const provider = createFailoverProvider([fastSpec, complexSpec], config);
199
+ return { provider, routed: true, classification };
200
+ }
@@ -155,6 +155,31 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
155
155
  return { ...container, stack: "make", testCmd: "make test", buildCmd: "make build", triggerFile: "Makefile" };
156
156
  }
157
157
 
158
+ // 4b. Web3 / Solidity / Foundry & Hardhat
159
+ if (existsSync(join(projectRoot, "foundry.toml")) || existsSync(join(projectRoot, "remappings.txt"))) {
160
+ const triggerFile = existsSync(join(projectRoot, "foundry.toml")) ? "foundry.toml" : "remappings.txt";
161
+ return {
162
+ ...container,
163
+ stack: "foundry",
164
+ testCmd: "forge test --offline",
165
+ buildCmd: "forge build --offline",
166
+ fmtCmd: "forge fmt --check",
167
+ fuzzCmd: "forge test --offline --match-test testFuzz",
168
+ invariantCmd: "forge test --offline --match-test invariant",
169
+ triggerFile,
170
+ };
171
+ }
172
+ if (existsSync(join(projectRoot, "hardhat.config.js")) || existsSync(join(projectRoot, "hardhat.config.ts"))) {
173
+ const triggerFile = existsSync(join(projectRoot, "hardhat.config.ts")) ? "hardhat.config.ts" : "hardhat.config.js";
174
+ return {
175
+ ...container,
176
+ stack: "hardhat",
177
+ testCmd: "npx hardhat test",
178
+ buildCmd: "npx hardhat compile",
179
+ triggerFile,
180
+ };
181
+ }
182
+
158
183
  // 5. Python / Django / Elixir / Ruby / Java
159
184
  if (existsSync(join(projectRoot, "manage.py"))) {
160
185
  return { ...container, stack: "django", testCmd: "python manage.py test --keepdb", buildCmd: "python manage.py check", triggerFile: "manage.py" };
@@ -247,6 +272,9 @@ export function findSubprojectRoot(fileRelPath = "", root = process.cwd()) {
247
272
 
248
273
  const manifestFiles = [
249
274
  "package.json",
275
+ "foundry.toml",
276
+ "hardhat.config.js",
277
+ "hardhat.config.ts",
250
278
  "Cargo.toml",
251
279
  "go.mod",
252
280
  "pyproject.toml",
@@ -1,5 +1,5 @@
1
- import { writeFileSync, mkdirSync, existsSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { writeFileSync, readFileSync, readdirSync, mkdirSync, existsSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
3
  import { loadConfig } from "./config.mjs";
4
4
  import { gate } from "./engine.mjs";
5
5
  import { scanDiff, shannonEntropy } from "./security.mjs";
@@ -21,9 +21,38 @@ HARD CONSTRAINTS:
21
21
 
22
22
  const TRIVIAL_ORACLES = new Set(["true", "echo", ":", "false", "exit 0", "exit 1", "echo ok"]);
23
23
 
24
+ /**
25
+ * Resolves specialist agent role markdown prompt from .agent/prompts/
26
+ * @param {string} [root=process.cwd()]
27
+ * @param {string} [roleName=""]
28
+ * @returns {{ role: string, path: string, content: string } | null}
29
+ */
30
+ export function resolveRolePrompt(root = process.cwd(), roleName = "") {
31
+ if (!roleName || typeof roleName !== "string") return null;
32
+ const cleanName = roleName.trim().toLowerCase();
33
+ const promptsDir = join(root, ".agent", "prompts");
34
+ if (!existsSync(promptsDir)) return null;
35
+
36
+ try {
37
+ const files = readdirSync(promptsDir);
38
+ const matched = files.find(
39
+ (f) => f.toLowerCase() === `${cleanName}.md` || f.toLowerCase() === cleanName
40
+ );
41
+ if (matched) {
42
+ const fullPath = join(promptsDir, matched);
43
+ return {
44
+ role: matched.replace(/\.md$/i, ""),
45
+ path: fullPath,
46
+ content: readFileSync(fullPath, "utf-8").trim(),
47
+ };
48
+ }
49
+ } catch (_) {}
50
+ return null;
51
+ }
52
+
24
53
  /**
25
54
  * Pure planning core for task creation & envelope synthesis.
26
- * Supports web task templates, exploration budgets, and critic agent steering.
55
+ * Supports web task templates, specialist roles, exploration budgets, and critic agent steering.
27
56
  * @param {string} root
28
57
  * @param {object} inputObj
29
58
  * @returns {{
@@ -33,6 +62,8 @@ const TRIVIAL_ORACLES = new Set(["true", "echo", ":", "false", "exit 0", "exit 1
33
62
  * prompt: string,
34
63
  * fullPrompt: string,
35
64
  * verifyCmd: string,
65
+ * role?: string,
66
+ * dependsOn?: string[],
36
67
  * flags: { autoPr: boolean, requirePlanApproval: boolean, repoless: boolean, startingBranch: string },
37
68
  * secretFindings: Array<any>,
38
69
  * taskFileContent: string
@@ -45,7 +76,22 @@ export function planTaskCreate(root = process.cwd(), inputObj = {}) {
45
76
  let rawPrompt = inputObj.prompt || "";
46
77
  let verifyCmd = (inputObj.verifyCmd || config.verify.test || config.verify.build || "").trim();
47
78
 
48
- // 0. Process Template if specified
79
+ // 0a. Process Specialist Role if specified
80
+ let resolvedRole = null;
81
+ if (inputObj.role) {
82
+ resolvedRole = resolveRolePrompt(root, inputObj.role);
83
+ if (!resolvedRole) {
84
+ throw new Error(
85
+ `Unknown agent role '${inputObj.role}'. Expected matching prompt file in .agent/prompts/ (e.g. Overseer, Bolt, Sentinel, Janitor).`
86
+ );
87
+ }
88
+ rawPrompt = `${resolvedRole.content}\n\n${rawPrompt}`.trim();
89
+ if (!title || title === "Agent Task") {
90
+ title = `${resolvedRole.role}: ${inputObj.title || "Specialist Task"}`;
91
+ }
92
+ }
93
+
94
+ // 0b. Process Template if specified
49
95
  if (inputObj.template) {
50
96
  const tpl = getWebTemplate(inputObj.template);
51
97
  if (!tpl) {
@@ -57,7 +103,7 @@ export function planTaskCreate(root = process.cwd(), inputObj = {}) {
57
103
  criticGuidance: inputObj.criticGuidance
58
104
  });
59
105
  rawPrompt = rawPrompt ? `${rawPrompt}\n\n${synthesized.prompt}` : synthesized.fullEnvelope;
60
- if (!title) title = synthesized.title;
106
+ if (!title || title === "Agent Task") title = synthesized.title;
61
107
  if (!verifyCmd) verifyCmd = synthesized.verifyCmd;
62
108
  }
63
109
 
@@ -123,12 +169,24 @@ ${GUARDRAIL_FOOTER}`;
123
169
 
124
170
  const promptAnalysis = scorePromptFalsifiability(rawPrompt, { rootDir: root, verifyCmd });
125
171
 
172
+ const rawDepends = inputObj.dependsOn || inputObj.depends;
173
+ const dependsOn = Array.isArray(rawDepends)
174
+ ? rawDepends
175
+ : typeof rawDepends === "string"
176
+ ? rawDepends.split(",").map((s) => s.trim()).filter(Boolean)
177
+ : undefined;
178
+
179
+ const tier = inputObj.tier === "fast" || inputObj.tier === "complex" ? inputObj.tier : undefined;
180
+
126
181
  const envelopeMetadata = {
127
182
  version: 1,
128
183
  id: taskId,
129
184
  title,
130
185
  flags,
131
186
  verifyCmd,
187
+ role: resolvedRole ? resolvedRole.role : (inputObj.role || undefined),
188
+ dependsOn,
189
+ tier,
132
190
  falsifiabilityScore: promptAnalysis.score,
133
191
  grade: promptAnalysis.grade,
134
192
  };
@@ -148,6 +206,9 @@ ${fullPrompt}
148
206
  prompt: rawPrompt,
149
207
  fullPrompt,
150
208
  verifyCmd,
209
+ role: envelopeMetadata.role,
210
+ dependsOn,
211
+ tier,
151
212
  flags,
152
213
  secretFindings,
153
214
  promptAnalysis,