jules-orchestrator-kit 0.32.4 → 0.32.6

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,205 @@
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
+ // extractPathTokens only recognises "/" as a separator, but a Windows author
77
+ // naturally writes "src\auth\session.mjs" in a prompt. Without this the
78
+ // sensitive-path guard below never sees the path and the task can be routed
79
+ // to the cheap tier. targetFiles are separately normalised by normalizePath.
80
+ const promptText = String(task.prompt || "").replace(/\\/g, "/");
81
+ const fromPrompt = extractPathTokens(promptText);
82
+ const explicit = Array.isArray(task.targetFiles)
83
+ ? task.targetFiles
84
+ : Array.isArray(task.referenced_paths)
85
+ ? task.referenced_paths
86
+ : [];
87
+ return [...new Set([...fromPrompt, ...explicit])];
88
+ }
89
+
90
+ function touchesSensitivePath(paths, config) {
91
+ const denyPatterns = [...SENSITIVE_PATH_PATTERNS, ...((config && config.scope && config.scope.deny) || [])];
92
+ for (const rawPath of paths) {
93
+ const p = normalizePath(rawPath);
94
+ for (const pattern of denyPatterns) {
95
+ if (matchesGlob(p, pattern)) return { path: p, pattern };
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Heuristically classifies a task envelope into a FAST or COMPLEX routing tier.
103
+ * @param {object} task - Task envelope (title, prompt, role, targetFiles, tier).
104
+ * @param {object} config - Loaded orchestrator config (uses scope.deny, router.threshold).
105
+ * @returns {{tier: string, score: number|null, forced: boolean, reason: string, signals?: string[]}}
106
+ */
107
+ export function classifyTaskComplexity(task = {}, config = {}) {
108
+ const explicitTier = task.tier || task.complexity;
109
+ if (explicitTier === ROUTE_TIERS.FAST || explicitTier === ROUTE_TIERS.COMPLEX) {
110
+ return { tier: explicitTier, score: null, forced: true, reason: `Explicit task tier override: '${explicitTier}'` };
111
+ }
112
+
113
+ const paths = collectReferencedPaths(task);
114
+ const sensitiveHit = touchesSensitivePath(paths, config);
115
+ if (sensitiveHit) {
116
+ return {
117
+ tier: ROUTE_TIERS.COMPLEX,
118
+ score: null,
119
+ forced: true,
120
+ reason: `Touches sensitive path '${sensitiveHit.path}' (matches '${sensitiveHit.pattern}')`,
121
+ };
122
+ }
123
+
124
+ const role = String(task.role || "").toLowerCase();
125
+ if (FORCE_COMPLEX_ROLES.has(role)) {
126
+ return { tier: ROUTE_TIERS.COMPLEX, score: null, forced: true, reason: `Role '${role}' always routes to the primary provider` };
127
+ }
128
+
129
+ const text = `${task.title || ""} ${task.prompt || ""}`;
130
+ let score = 0;
131
+ const signals = [];
132
+
133
+ for (const pattern of COMPLEX_SIGNALS) {
134
+ if (pattern.test(text)) {
135
+ score += 2;
136
+ signals.push(`+2 complex keyword (${pattern.source})`);
137
+ }
138
+ }
139
+ for (const pattern of TRIVIAL_SIGNALS) {
140
+ if (pattern.test(text)) {
141
+ score -= 1;
142
+ signals.push(`-1 trivial keyword (${pattern.source})`);
143
+ }
144
+ }
145
+
146
+ if (paths.length >= 4) {
147
+ score += 3;
148
+ signals.push(`+3 touches ${paths.length} files`);
149
+ } else if (paths.length > 0 && paths.length <= 1) {
150
+ score -= 1;
151
+ signals.push(`-1 touches a single file`);
152
+ }
153
+
154
+ const promptLen = (task.prompt || "").length;
155
+ if (promptLen > 1200) {
156
+ score += 1;
157
+ signals.push(`+1 long prompt (${promptLen} chars)`);
158
+ } else if (promptLen > 0 && promptLen < 200) {
159
+ score -= 1;
160
+ signals.push(`-1 short prompt (${promptLen} chars)`);
161
+ }
162
+
163
+ if (COMPLEX_LEANING_ROLES.has(role)) {
164
+ score += 1;
165
+ signals.push(`+1 role '${role}' leans complex`);
166
+ }
167
+ if (FAST_LEANING_ROLES.has(role)) {
168
+ score -= 2;
169
+ signals.push(`-2 role '${role}' leans fast`);
170
+ }
171
+
172
+ const threshold = Number.isFinite(config?.router?.threshold) ? config.router.threshold : 0;
173
+ const tier = score > threshold ? ROUTE_TIERS.COMPLEX : ROUTE_TIERS.FAST;
174
+
175
+ return { tier, score, forced: false, threshold, signals, reason: `Heuristic score ${score} vs threshold ${threshold}` };
176
+ }
177
+
178
+ /**
179
+ * Resolves the provider adapter to dispatch a task through, honoring
180
+ * `config.router` (opt-in Dynamic Complexity & Cost Router). Disabled by
181
+ * default — returns the primary provider unchanged. FAST-tier tasks are
182
+ * wrapped in a failover cascade so a rate-limited/unavailable fast provider
183
+ * falls through to the primary provider automatically.
184
+ * @param {object} task
185
+ * @param {object} config
186
+ * @returns {{provider: object, routed: boolean, classification: object|null}}
187
+ */
188
+ export function resolveRoutedProvider(task = {}, config = {}) {
189
+ const routerCfg = config.router || {};
190
+ const complexSpec = routerCfg.complex || config.provider || "jules";
191
+
192
+ if (!routerCfg.enabled) {
193
+ return { provider: createProvider(config.provider, config), routed: false, classification: null };
194
+ }
195
+
196
+ const classification = classifyTaskComplexity(task, config);
197
+
198
+ if (classification.tier === ROUTE_TIERS.COMPLEX) {
199
+ return { provider: createProvider(complexSpec, config), routed: true, classification };
200
+ }
201
+
202
+ const fastSpec = routerCfg.fast || "gemini-flash";
203
+ const provider = createFailoverProvider([fastSpec, complexSpec], config);
204
+ return { provider, routed: true, classification };
205
+ }
package/src/security.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join, basename } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
- import { normalizePath } from "./config.mjs";
4
+ import { canonicalizePath } from "./config.mjs";
5
5
  import { detectCrossPackageBoundaryViolations } from "./stack-detector.mjs";
6
6
 
7
7
  export const HIGH_CONFIDENCE_PATTERNS = [
@@ -165,12 +165,28 @@ export function anonymizePii(text) {
165
165
  return sanitized;
166
166
  }
167
167
 
168
- export function matchesGlob(filePath, globPattern) {
168
+ /**
169
+ * Glob matcher.
170
+ *
171
+ * `caseInsensitive` exists because the same repository is checked out on
172
+ * Linux, macOS and Windows. On APFS and NTFS, `.GitHub/` and `.github/` are
173
+ * the *same directory*, but git records whichever case was committed — so a
174
+ * case-sensitive deny rule can be walked straight past on two of the three
175
+ * target platforms. Deny and protect matching therefore folds case; allow
176
+ * matching deliberately does not, so that a case mismatch fails closed
177
+ * (unmatched by allow = violation) rather than opening a hole.
178
+ *
179
+ * @param {string} filePath
180
+ * @param {string} globPattern
181
+ * @param {{ caseInsensitive?: boolean }} [opts]
182
+ */
183
+ export function matchesGlob(filePath, globPattern, opts = {}) {
169
184
  if (!filePath || !globPattern) return false;
170
- const file = normalizePath(filePath);
171
- const pattern = normalizePath(globPattern);
185
+ const file = canonicalizePath(filePath);
186
+ const pattern = canonicalizePath(globPattern);
187
+ const flags = opts.caseInsensitive ? "i" : "";
172
188
 
173
- if (file === pattern) return true;
189
+ if (opts.caseInsensitive ? file.toLowerCase() === pattern.toLowerCase() : file === pattern) return true;
174
190
 
175
191
  const parts = pattern.split("/");
176
192
  const regexParts = parts.map((part) => {
@@ -188,16 +204,16 @@ export function matchesGlob(filePath, globPattern) {
188
204
  .replace(/___GLOBSTAR___/g, ".*");
189
205
 
190
206
  try {
191
- return new RegExp(`^${regexStr}$`).test(file);
207
+ return new RegExp(`^${regexStr}$`, flags).test(file);
192
208
  } catch (_) {
193
209
  return false;
194
210
  }
195
211
  }
196
212
 
197
213
  export function isForbiddenPath(filePath, config = {}) {
198
- const normFile = normalizePath(filePath);
214
+ const normFile = canonicalizePath(filePath);
199
215
  const forbidden = config.scope?.deny || config.forbidden_paths || [];
200
- return forbidden.some((pattern) => matchesGlob(normFile, pattern));
216
+ return forbidden.some((pattern) => matchesGlob(normFile, pattern, { caseInsensitive: true }));
201
217
  }
202
218
 
203
219
  export function checkScope(files = [], scope = {}, opts = {}) {
@@ -207,14 +223,27 @@ export function checkScope(files = [], scope = {}, opts = {}) {
207
223
  const protect = scope.protect || [];
208
224
 
209
225
  for (const rawFile of files) {
210
- const file = normalizePath(rawFile);
226
+ // Canonicalised so that "./x", "a/../x" and "a//x" cannot present the same
227
+ // file under a spelling the deny patterns do not literally match.
228
+ const file = canonicalizePath(rawFile);
229
+
230
+ // A path that climbs out of the repository root can never be legitimate and
231
+ // must not be silently pattern-matched against repo-relative rules.
232
+ if (file === ".." || file.startsWith("../") || file.startsWith("/")) {
233
+ violations.push({ file, reason: "Path escapes the repository root", rule: "deny", pattern: "<traversal>" });
234
+ continue;
235
+ }
211
236
 
212
- const matchedDeny = deny.find((pat) => matchesGlob(file, pat));
237
+ // Deny folds case: on macOS/Windows ".GitHub/" resolves to the same
238
+ // directory as ".github/", so a case-sensitive deny is bypassable there.
239
+ const matchedDeny = deny.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
213
240
  if (matchedDeny) {
214
241
  violations.push({ file, reason: `Forbidden path restriction matched pattern "${matchedDeny}"`, rule: "deny", pattern: matchedDeny });
215
242
  continue;
216
243
  }
217
244
 
245
+ // Allow stays case-sensitive on purpose: a case mismatch here yields "not
246
+ // allowed" (a violation), which is the fail-closed direction.
218
247
  if (allow.length > 0) {
219
248
  const isExplicitlyAllowed = allow.some((pat) => matchesGlob(file, pat));
220
249
  if (!isExplicitlyAllowed) {
@@ -224,7 +253,7 @@ export function checkScope(files = [], scope = {}, opts = {}) {
224
253
  }
225
254
 
226
255
  if (!opts.allowProtected) {
227
- const matchedProtect = protect.find((pat) => matchesGlob(file, pat));
256
+ const matchedProtect = protect.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
228
257
  if (matchedProtect) {
229
258
  violations.push({ file, reason: `Protected file modification restriction matched pattern "${matchedProtect}"`, rule: "protect", pattern: matchedProtect });
230
259
  }
@@ -332,6 +361,29 @@ export function checkCrossPackageImports(diffOrText = "", root = process.cwd(),
332
361
  };
333
362
  }
334
363
 
364
+ // Zero-width and bidi-control characters. Inserting one mid-token defeats a
365
+ // regex without changing how the value renders, copies, or authenticates.
366
+ const INVISIBLE_CHARS = /[\u00AD\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2064\uFEFF]/g;
367
+
368
+ // A credential split across a source-level string concatenation is invisible to
369
+ // a line-oriented scanner. This is not only an evasion technique — formatters
370
+ // wrap long string literals exactly this way, so it also happens by accident.
371
+ const STRING_CONCAT_JOIN = /(["'`])\s*\+\s*(["'`])/g;
372
+
373
+ /**
374
+ * Produces the variants of the added-line text that secret patterns are run
375
+ * against: as-written, with invisible characters stripped, and with
376
+ * source-level string concatenation collapsed.
377
+ * @param {string} addedLines
378
+ * @returns {string[]}
379
+ */
380
+ function secretScanVariants(addedLines) {
381
+ const stripped = addedLines.replace(INVISIBLE_CHARS, "");
382
+ // Collapse `"AAA" +\n "BBB"` into `"AAABBB"` before matching.
383
+ const dejoined = stripped.replace(/\s*\n\s*/g, " ").replace(STRING_CONCAT_JOIN, "");
384
+ return [...new Set([addedLines, stripped, dejoined])];
385
+ }
386
+
335
387
  export function scanDiff(diffTextStr = "", options = {}) {
336
388
  if (!diffTextStr) return { ok: true, findings: [] };
337
389
  const addedLines = diffTextStr
@@ -340,8 +392,9 @@ export function scanDiff(diffTextStr = "", options = {}) {
340
392
  .map((line) => line.slice(1))
341
393
  .join("\n");
342
394
 
343
- const hasHigh = hasHighConfidenceSecret(addedLines);
344
- const hasLow = !hasHigh && hasLowConfidenceSecret(addedLines);
395
+ const variants = secretScanVariants(addedLines);
396
+ const hasHigh = variants.some((v) => hasHighConfidenceSecret(v));
397
+ const hasLow = !hasHigh && variants.some((v) => hasLowConfidenceSecret(v));
345
398
  const findings = [];
346
399
 
347
400
  if (hasHigh) {
@@ -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,