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/JULES_RULES_TEMPLATE.md +2 -1
- package/README.md +48 -12
- package/bin/agentctl.mjs +122 -13
- package/index.mjs +19 -4
- package/package.json +2 -1
- package/scripts/command-resolver.mjs +15 -1
- package/scripts/doc-sync-check.mjs +234 -0
- package/scripts/release.mjs +23 -2
- package/src/config.mjs +66 -0
- package/src/dag-engine.mjs +118 -0
- package/src/engine.mjs +237 -71
- package/src/evidence.mjs +435 -0
- package/src/mcp.mjs +39 -1
- package/src/ops/evidence-actions.mjs +74 -0
- package/src/provider.mjs +37 -7
- package/src/router.mjs +205 -0
- package/src/security.mjs +66 -13
- package/src/stack-detector.mjs +28 -0
- package/src/wizard-task.mjs +66 -5
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 {
|
|
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
|
-
|
|
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 =
|
|
171
|
-
const pattern =
|
|
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}
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
344
|
-
const
|
|
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) {
|
package/src/stack-detector.mjs
CHANGED
|
@@ -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",
|
package/src/wizard-task.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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,
|