jules-orchestrator-kit 0.8.1 → 0.9.3
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/.agent/jules-queue/README.md +4 -4
- package/.agent/prompts/Bolt.md +18 -0
- package/.agent/prompts/Overseer.md +18 -0
- package/.agent/prompts/Sentinel.md +18 -0
- package/.agent/prompts/Task_Template.md +26 -0
- package/.agent/rules/dynamic-guardrails.json +0 -4
- package/.github/workflows/jules-audit.yml +16 -5
- package/JULES_RULES_TEMPLATE.md +7 -2
- package/README.md +113 -21
- package/bin/agentctl.mjs +274 -0
- package/bin/init.js +1 -1
- package/index.mjs +34 -6
- package/package.json +22 -6
- package/scripts/asset-integrity-check.mjs +35 -0
- package/scripts/command-resolver.mjs +37 -214
- package/scripts/jules-cleanup.mjs +23 -174
- package/scripts/jules-create.mjs +12 -51
- package/scripts/jules-dispatch.mjs +103 -349
- package/scripts/jules-merge-swarm.mjs +30 -0
- package/scripts/jules-nightly.mjs +7 -124
- package/scripts/jules-patch.mjs +23 -0
- package/scripts/jules-queue-runner.mjs +28 -203
- package/scripts/jules-scan-todos.mjs +31 -125
- package/scripts/jules-self-audit.mjs +76 -578
- package/scripts/jules-status.mjs +42 -65
- package/scripts/jules-swarm.mjs +27 -232
- package/scripts/jules-webhook-receiver.mjs +70 -0
- package/scripts/lock-manager.mjs +11 -170
- package/scripts/risk-tier.mjs +29 -0
- package/scripts/rules-lint.mjs +20 -0
- package/scripts/stale-base-check.mjs +26 -0
- package/scripts/utils.mjs +118 -252
- package/scripts/validate-envelope.mjs +44 -0
- package/src/asset_integrity.mjs +76 -0
- package/src/config.mjs +325 -0
- package/src/engine.mjs +217 -0
- package/src/envelope.mjs +105 -0
- package/src/git.mjs +127 -0
- package/src/provider.mjs +179 -0
- package/src/risk.mjs +120 -0
- package/src/rules_budget.mjs +77 -0
- package/src/security.mjs +212 -0
- package/src/state.mjs +209 -0
- package/src/webhook.mjs +138 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -63
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -34
- package/.github/social-preview.png +0 -0
- package/.github/workflows/agent-scope-guard.yml +0 -57
- package/.github/workflows/jules-nightly.yml +0 -21
- package/.github/workflows/publish.yml +0 -24
package/src/config.mjs
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export class ConfigError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ConfigError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
version: 1,
|
|
14
|
+
provider: "jules",
|
|
15
|
+
verify: {},
|
|
16
|
+
scope: { deny: [], allow: [], protect: [] },
|
|
17
|
+
limits: {
|
|
18
|
+
diffKb: 75,
|
|
19
|
+
promptKb: 50,
|
|
20
|
+
dailyTasks: 300,
|
|
21
|
+
repairAttempts: 3,
|
|
22
|
+
concurrency: 1,
|
|
23
|
+
staggerMs: 1500,
|
|
24
|
+
},
|
|
25
|
+
isolation: "none",
|
|
26
|
+
runner: "local",
|
|
27
|
+
branchPrefix: "agent/",
|
|
28
|
+
baseBranch: "main",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const BUILTIN_DENY = [
|
|
32
|
+
".git/**",
|
|
33
|
+
"**/.env",
|
|
34
|
+
"**/.env.*",
|
|
35
|
+
"**/*.pem",
|
|
36
|
+
"**/*.key",
|
|
37
|
+
"**/id_rsa*",
|
|
38
|
+
".agent/config.yml",
|
|
39
|
+
".agent/jules.yml",
|
|
40
|
+
".agent/jules-queue/**",
|
|
41
|
+
".github/**",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export const BUILTIN_PROTECT = [
|
|
45
|
+
".agent/rules/**",
|
|
46
|
+
"package.json",
|
|
47
|
+
"Cargo.toml",
|
|
48
|
+
"pyproject.toml",
|
|
49
|
+
"go.mod",
|
|
50
|
+
"Makefile",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
54
|
+
|
|
55
|
+
function coerce(val) {
|
|
56
|
+
if (!val) return "";
|
|
57
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
58
|
+
return val.slice(1, -1);
|
|
59
|
+
}
|
|
60
|
+
if (val === "true") return true;
|
|
61
|
+
if (val === "false") return false;
|
|
62
|
+
if (!isNaN(Number(val)) && val.trim() !== "") return Number(val);
|
|
63
|
+
return val;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizePath(p) {
|
|
67
|
+
if (!p || typeof p !== "string") return "";
|
|
68
|
+
return p.split(sep).join("/").replace(/\\/g, "/");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveRoot(cwd = process.cwd()) {
|
|
72
|
+
try {
|
|
73
|
+
return execSync("git rev-parse --show-toplevel", { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
74
|
+
} catch (_) {
|
|
75
|
+
return resolve(cwd);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function dedupe(arr) {
|
|
80
|
+
return Array.from(new Set((arr || []).filter(Boolean)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Single authoritative scope normalizer. Always merges BUILTIN_DENY and BUILTIN_PROTECT.
|
|
85
|
+
* CRITICAL FIX FOR B1: User scope.deny will add to BUILTIN_DENY, never replace it.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeScope(parsed = {}) {
|
|
88
|
+
const forbiddenPaths = Array.isArray(parsed.forbidden_paths)
|
|
89
|
+
? parsed.forbidden_paths
|
|
90
|
+
: Array.isArray(parsed.scope?.deny)
|
|
91
|
+
? parsed.scope.deny
|
|
92
|
+
: [];
|
|
93
|
+
const allowPaths = Array.isArray(parsed.allow_paths)
|
|
94
|
+
? parsed.allow_paths
|
|
95
|
+
: Array.isArray(parsed.scope?.allow)
|
|
96
|
+
? parsed.scope.allow
|
|
97
|
+
: [];
|
|
98
|
+
const protectPaths = Array.isArray(parsed.scope?.protect) ? parsed.scope.protect : [];
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
deny: dedupe([...BUILTIN_DENY, ...forbiddenPaths]),
|
|
102
|
+
allow: dedupe(allowPaths),
|
|
103
|
+
protect: dedupe([...BUILTIN_PROTECT, ...protectPaths]),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Indent-stack zero-dependency YAML parser with prototype pollution protection.
|
|
109
|
+
*/
|
|
110
|
+
export function parseYaml(src) {
|
|
111
|
+
if (!src || typeof src !== "string") return Object.create(null);
|
|
112
|
+
const root = Object.create(null);
|
|
113
|
+
const stack = [{ indent: -1, node: root }];
|
|
114
|
+
const lines = src.split("\n");
|
|
115
|
+
|
|
116
|
+
for (let rawLine of lines) {
|
|
117
|
+
const commentIdx = rawLine.indexOf("#");
|
|
118
|
+
const line = commentIdx !== -1 ? rawLine.slice(0, commentIdx) : rawLine;
|
|
119
|
+
const trimmed = line.trim();
|
|
120
|
+
if (!trimmed) continue;
|
|
121
|
+
|
|
122
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
123
|
+
|
|
124
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
125
|
+
stack.pop();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const top = stack[stack.length - 1];
|
|
129
|
+
|
|
130
|
+
if (trimmed.startsWith("- ")) {
|
|
131
|
+
if (top.key && top.parent) {
|
|
132
|
+
if (!Array.isArray(top.parent[top.key])) {
|
|
133
|
+
top.parent[top.key] = [];
|
|
134
|
+
}
|
|
135
|
+
top.parent[top.key].push(coerce(trimmed.slice(2).trim()));
|
|
136
|
+
} else if (Array.isArray(top.node)) {
|
|
137
|
+
top.node.push(coerce(trimmed.slice(2).trim()));
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const eqIdx = trimmed.indexOf(":");
|
|
143
|
+
if (eqIdx > 0) {
|
|
144
|
+
const rawKey = trimmed.slice(0, eqIdx).trim();
|
|
145
|
+
const valStr = trimmed.slice(eqIdx + 1).trim();
|
|
146
|
+
|
|
147
|
+
if (BLOCKED_KEYS.has(rawKey)) {
|
|
148
|
+
throw new ConfigError(`Illegal prototype key "${rawKey}" detected in configuration`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (valStr === "") {
|
|
152
|
+
const child = Object.create(null);
|
|
153
|
+
top.node[rawKey] = child;
|
|
154
|
+
stack.push({ indent, node: child, key: rawKey, parent: top.node });
|
|
155
|
+
} else if (valStr.startsWith("[") && valStr.endsWith("]")) {
|
|
156
|
+
const items = valStr
|
|
157
|
+
.slice(1, -1)
|
|
158
|
+
.split(",")
|
|
159
|
+
.map((s) => coerce(s.trim()))
|
|
160
|
+
.filter((s) => s !== "");
|
|
161
|
+
top.node[rawKey] = items;
|
|
162
|
+
} else {
|
|
163
|
+
top.node[rawKey] = coerce(valStr);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return root;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function detectPackageManager(root = process.cwd(), pkg = {}) {
|
|
172
|
+
if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
173
|
+
if (existsSync(join(root, "yarn.lock"))) return "yarn";
|
|
174
|
+
if (existsSync(join(root, "bun.lockb"))) return "bun";
|
|
175
|
+
if (existsSync(join(root, "package-lock.json"))) return "npm";
|
|
176
|
+
|
|
177
|
+
const pm = pkg.packageManager;
|
|
178
|
+
if (typeof pm === "string") {
|
|
179
|
+
if (pm.startsWith("pnpm")) return "pnpm";
|
|
180
|
+
if (pm.startsWith("yarn")) return "yarn";
|
|
181
|
+
if (pm.startsWith("bun")) return "bun";
|
|
182
|
+
if (pm.startsWith("npm")) return "npm";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return "npm";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Autodetects verification test/build commands across 16 common tech stacks.
|
|
190
|
+
*/
|
|
191
|
+
export function detectStack(projectRoot = process.cwd()) {
|
|
192
|
+
const detectors = [
|
|
193
|
+
{
|
|
194
|
+
files: ["turbo.json"],
|
|
195
|
+
resolve: () => ({ testCmd: "npx turbo run test", buildCmd: "npx turbo run build", stack: "turbo" }),
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
files: ["pnpm-workspace.yaml"],
|
|
199
|
+
resolve: () => ({ testCmd: "pnpm -r test", buildCmd: "pnpm -r build", stack: "pnpm" }),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
files: ["nx.json"],
|
|
203
|
+
resolve: () => ({ testCmd: "npx nx run-many -t test", buildCmd: "npx nx run-many -t build", stack: "nx" }),
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
files: ["bunfig.toml", "bun.lockb"],
|
|
207
|
+
resolve: () => ({ testCmd: "bun test", buildCmd: "bun run build", stack: "bun" }),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
files: ["deno.json", "deno.jsonc"],
|
|
211
|
+
resolve: () => ({ testCmd: "deno test", buildCmd: "deno task build", stack: "deno" }),
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
files: ["package.json"],
|
|
215
|
+
resolve: (root) => {
|
|
216
|
+
try {
|
|
217
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
|
|
218
|
+
const pm = detectPackageManager(root, pkg);
|
|
219
|
+
const scripts = pkg.scripts || {};
|
|
220
|
+
let testScript = scripts.test ? `${pm} test` : "";
|
|
221
|
+
const buildScript = scripts.build ? `${pm} run build` : "";
|
|
222
|
+
return { testCmd: testScript || `${pm} test`, buildCmd: buildScript, stack: "node" };
|
|
223
|
+
} catch (_) {
|
|
224
|
+
return { testCmd: "npm test", buildCmd: "npm run build", stack: "node" };
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
files: ["Cargo.toml"],
|
|
230
|
+
resolve: () => ({ testCmd: "cargo test --workspace", buildCmd: "cargo build", stack: "cargo" }),
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
files: ["go.mod"],
|
|
234
|
+
resolve: () => ({ testCmd: "go test ./...", buildCmd: "go build ./...", stack: "go" }),
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
files: ["pyproject.toml", "requirements.txt", "setup.py"],
|
|
238
|
+
resolve: () => ({ testCmd: "pytest", buildCmd: "", stack: "python" }),
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
files: ["mix.exs"],
|
|
242
|
+
resolve: () => ({ testCmd: "mix test", buildCmd: "mix compile", stack: "mix" }),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
files: ["Gemfile"],
|
|
246
|
+
resolve: () => ({ testCmd: "bundle exec rake test", buildCmd: "", stack: "bundler" }),
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
files: ["Package.swift"],
|
|
250
|
+
resolve: () => ({ testCmd: "swift test", buildCmd: "swift build", stack: "swift" }),
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
files: ["pom.xml"],
|
|
254
|
+
resolve: () => ({ testCmd: "mvn test", buildCmd: "mvn compile", stack: "maven" }),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
files: ["build.gradle", "build.gradle.kts"],
|
|
258
|
+
resolve: () => ({ testCmd: "./gradlew test", buildCmd: "./gradlew assemble", stack: "gradle" }),
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
files: ["Makefile"],
|
|
262
|
+
resolve: () => ({ testCmd: "make test", buildCmd: "make build", stack: "make" }),
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
for (const detector of detectors) {
|
|
267
|
+
if (detector.files.some((f) => existsSync(join(projectRoot, f)))) {
|
|
268
|
+
const res = detector.resolve(projectRoot);
|
|
269
|
+
if (res) return res;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { testCmd: "", buildCmd: "", stack: "unknown" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function resolveVerify(root = process.cwd()) {
|
|
277
|
+
const s = detectStack(root);
|
|
278
|
+
return { test: s.testCmd || "", build: s.buildCmd || "" };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Loads and validates configuration from .agent/config.yml or .agent/jules.yml.
|
|
283
|
+
*/
|
|
284
|
+
export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
285
|
+
const candidates = explicitPath
|
|
286
|
+
? [explicitPath]
|
|
287
|
+
: [join(root, ".agent/config.yml"), join(root, ".agent/jules.yml")];
|
|
288
|
+
|
|
289
|
+
const configFile = candidates.find(existsSync);
|
|
290
|
+
let parsed = Object.create(null);
|
|
291
|
+
if (configFile) {
|
|
292
|
+
try {
|
|
293
|
+
const raw = readFileSync(configFile, "utf-8");
|
|
294
|
+
parsed = parseYaml(raw);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err instanceof ConfigError) throw err;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const testCmd = parsed.test_cmd || parsed.verify?.test || "";
|
|
301
|
+
const buildCmd = parsed.build_cmd || parsed.verify?.build || "";
|
|
302
|
+
const autoVerify = resolveVerify(root);
|
|
303
|
+
|
|
304
|
+
const config = {
|
|
305
|
+
version: parsed.version || DEFAULTS.version,
|
|
306
|
+
provider: parsed.provider || DEFAULTS.provider,
|
|
307
|
+
verify: {
|
|
308
|
+
test: testCmd || autoVerify.test,
|
|
309
|
+
build: buildCmd || autoVerify.build,
|
|
310
|
+
},
|
|
311
|
+
scope: normalizeScope(parsed),
|
|
312
|
+
limits: {
|
|
313
|
+
...DEFAULTS.limits,
|
|
314
|
+
...(parsed.limits || {}),
|
|
315
|
+
},
|
|
316
|
+
isolation: parsed.isolation || DEFAULTS.isolation,
|
|
317
|
+
runner: parsed.runner || DEFAULTS.runner,
|
|
318
|
+
branchPrefix: parsed.branch_prefix || parsed.branchPrefix || DEFAULTS.branchPrefix,
|
|
319
|
+
baseBranch: parsed.base_branch || parsed.baseBranch || DEFAULTS.baseBranch,
|
|
320
|
+
_root: root,
|
|
321
|
+
_file: configFile || null,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
return config;
|
|
325
|
+
}
|
package/src/engine.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
|
|
2
|
+
import { checkScope, scanDiff, redactSecrets } from "./security.mjs";
|
|
3
|
+
import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git.mjs";
|
|
4
|
+
import { createProvider } from "./provider.mjs";
|
|
5
|
+
import { withBudget, appendLedger, getQueueDir, ensureDir } from "./state.mjs";
|
|
6
|
+
import { readdirSync, readFileSync, renameSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Gatekeeper verification engine (Phase 1: Scope, Phase 2: Payload, Phase 3: Secrets, Phase 4: Verify Commands).
|
|
11
|
+
* FAILS CLOSED ON GIT OR CONFIG ERRORS.
|
|
12
|
+
*/
|
|
13
|
+
export async function gate(opts = {}) {
|
|
14
|
+
const root = opts.root || process.cwd();
|
|
15
|
+
const config = opts.config || loadConfig(root);
|
|
16
|
+
const base = opts.base || config.baseBranch || "main";
|
|
17
|
+
const phases = [];
|
|
18
|
+
|
|
19
|
+
let files = [];
|
|
20
|
+
let bytes = 0;
|
|
21
|
+
let diffStr = "";
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
files = changedFiles(root, base);
|
|
25
|
+
bytes = diffBytes(root, base);
|
|
26
|
+
diffStr = diffText(root, base);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
phases.push({ phase: "git_resolution", ok: false, error: err.message });
|
|
29
|
+
return { ok: false, code: 1, phases, error: err.message };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Phase 1: Scope Guard (Fetch trusted config strictly from origin/base using single normalizeScope)
|
|
33
|
+
let trustedScope = config.scope;
|
|
34
|
+
let trustedVerify = config.verify;
|
|
35
|
+
|
|
36
|
+
const trustedConfigRaw = showFromOrigin(root, base, ".agent/config.yml") || showFromOrigin(root, base, ".agent/jules.yml");
|
|
37
|
+
if (trustedConfigRaw) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = parseYaml(trustedConfigRaw);
|
|
40
|
+
// CRITICAL B1 FIX: normalizeScope ensures BUILTIN_DENY is ALWAYS merged with user deny rules
|
|
41
|
+
trustedScope = normalizeScope(parsed);
|
|
42
|
+
if (parsed.verify || parsed.test_cmd || parsed.build_cmd) {
|
|
43
|
+
trustedVerify = {
|
|
44
|
+
test: parsed.verify?.test || parsed.test_cmd || config.verify.test,
|
|
45
|
+
build: parsed.verify?.build || parsed.build_cmd || config.verify.build,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
} catch (_) {}
|
|
49
|
+
} else {
|
|
50
|
+
// CRITICAL H-c FIX: Fall back strictly to normalizeScope({}) (built-ins only), never HEAD config
|
|
51
|
+
trustedScope = normalizeScope({});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const scopeResult = checkScope(files, trustedScope, {
|
|
55
|
+
allowProtected: opts.allowProtected || process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
phases.push({ phase: "scope", ok: scopeResult.ok, violations: scopeResult.violations });
|
|
59
|
+
if (!scopeResult.ok) {
|
|
60
|
+
return { ok: false, code: 3, phases };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Phase 2: Diff Payload Governor
|
|
64
|
+
const limitBytes = (config.limits.diffKb || 75) * 1024;
|
|
65
|
+
const payloadOk = bytes <= limitBytes;
|
|
66
|
+
phases.push({ phase: "payload", ok: payloadOk, bytes, limitBytes });
|
|
67
|
+
if (!payloadOk) {
|
|
68
|
+
return { ok: false, code: 5, phases };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Phase 3: Diff Secret Scanner
|
|
72
|
+
const secretResult = scanDiff(diffStr);
|
|
73
|
+
phases.push({ phase: "secrets", ok: secretResult.ok, findings: secretResult.findings });
|
|
74
|
+
if (!secretResult.ok) {
|
|
75
|
+
return { ok: false, code: 6, phases };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Phase 4: Automated Test & Build Verification (Uses trusted verify commands only)
|
|
79
|
+
const testCmd = trustedVerify.test;
|
|
80
|
+
const buildCmd = trustedVerify.build;
|
|
81
|
+
let testResult = { ok: true, status: 0 };
|
|
82
|
+
let buildResult = { ok: true, status: 0 };
|
|
83
|
+
|
|
84
|
+
if (testCmd) {
|
|
85
|
+
const res = runCmd(testCmd, { cwd: root, ignoreError: true });
|
|
86
|
+
testResult = { ok: res.status === 0, status: res.status, stdout: res.stdout, stderr: res.stderr, command: testCmd };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (testResult.ok && buildCmd) {
|
|
90
|
+
const res = runCmd(buildCmd, { cwd: root, ignoreError: true });
|
|
91
|
+
buildResult = { ok: res.status === 0, status: res.status, stdout: res.stdout, stderr: res.stderr, command: buildCmd };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const verifyOk = testResult.ok && buildResult.ok;
|
|
95
|
+
const failingCmd = !testResult.ok ? testResult : !buildResult.ok ? buildResult : null;
|
|
96
|
+
phases.push({ phase: "verify", ok: verifyOk, testResult, buildResult });
|
|
97
|
+
|
|
98
|
+
if (!verifyOk && opts.fix) {
|
|
99
|
+
const repairs = await repair(failingCmd, { config, root, signal: opts.signal });
|
|
100
|
+
if (repairs.ok) {
|
|
101
|
+
return { ok: true, code: 0, phases, repairs };
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, code: 4, phases, repairs };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { ok: verifyOk, code: verifyOk ? 0 : 4, phases };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function repair(failure, opts = {}) {
|
|
110
|
+
const root = opts.root || process.cwd();
|
|
111
|
+
const config = opts.config || loadConfig(root);
|
|
112
|
+
const maxRetries = config.limits.repairAttempts || 3;
|
|
113
|
+
const provider = createProvider(config.provider, config);
|
|
114
|
+
const attempts = [];
|
|
115
|
+
|
|
116
|
+
let currentFailure = failure;
|
|
117
|
+
for (let n = 1; n <= maxRetries; n++) {
|
|
118
|
+
const repairPrompt = buildRepairPrompt(currentFailure, n, config);
|
|
119
|
+
let session;
|
|
120
|
+
try {
|
|
121
|
+
session = await withBudget(
|
|
122
|
+
() =>
|
|
123
|
+
provider.dispatch(
|
|
124
|
+
{ id: `repair-${n}`, title: `OODA Auto-Repair Attempt ${n}`, prompt: repairPrompt },
|
|
125
|
+
{ root, dryRun: opts.dryRun }
|
|
126
|
+
),
|
|
127
|
+
root,
|
|
128
|
+
config.limits.dailyTasks
|
|
129
|
+
);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
attempts.push({ n, ok: false, error: err.message });
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
attempts.push({ n, session, ok: true });
|
|
136
|
+
|
|
137
|
+
// Re-verify after repair attempt
|
|
138
|
+
const gateRes = await gate({ root, config, fix: false });
|
|
139
|
+
if (gateRes.ok) {
|
|
140
|
+
return { ok: true, attempts, finalStatus: "PASSED" };
|
|
141
|
+
}
|
|
142
|
+
currentFailure = gateRes.phases.find((p) => p.phase === "verify")?.testResult || failure;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { ok: false, attempts, finalStatus: "OODA_EXHAUSTED" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildRepairPrompt(failure, attempt, _config) {
|
|
149
|
+
const cleanStderr = redactSecrets(failure.stderr || failure.stdout || "Unknown Error");
|
|
150
|
+
return `Auto-Repair Attempt #${attempt}
|
|
151
|
+
Command Failed: ${failure.command || "verify"}
|
|
152
|
+
|
|
153
|
+
REPAIR DIRECTIVE & STRICT INVARIANTS:
|
|
154
|
+
1. Ground your fix strictly in the error log below. Do NOT guess file contents or function signatures.
|
|
155
|
+
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
|
+
3. CARRY EVIDENCE: Address the root cause directly and verify the exact fix.
|
|
157
|
+
4. RE-VERIFY: Execute full verification after patching to ensure zero new regressions.
|
|
158
|
+
|
|
159
|
+
Stderr:
|
|
160
|
+
${cleanStderr}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function dispatch(task, opts = {}) {
|
|
164
|
+
const root = opts.config?._root || opts.root || process.cwd();
|
|
165
|
+
const config = opts.config || loadConfig(root);
|
|
166
|
+
const provider = createProvider(config.provider, config);
|
|
167
|
+
|
|
168
|
+
// Enforce prompt size limit
|
|
169
|
+
const promptKb = (config.limits.promptKb || 50) * 1024;
|
|
170
|
+
if (task.prompt && Buffer.byteLength(task.prompt, "utf-8") > promptKb) {
|
|
171
|
+
throw new Error(`Task prompt exceeds maximum payload limit of ${config.limits.promptKb} KB`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Redact secrets in prompt before dispatching
|
|
175
|
+
const cleanPrompt = redactSecrets(task.prompt);
|
|
176
|
+
const cleanTask = { ...task, prompt: cleanPrompt };
|
|
177
|
+
|
|
178
|
+
return withBudget(
|
|
179
|
+
() => provider.dispatch(cleanTask, { root, dryRun: opts.dryRun }),
|
|
180
|
+
root,
|
|
181
|
+
config.limits.dailyTasks
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function run(opts = {}) {
|
|
186
|
+
const root = opts.root || process.cwd();
|
|
187
|
+
const config = opts.config || loadConfig(root);
|
|
188
|
+
const concurrency = opts.concurrency || config.limits.concurrency || 1;
|
|
189
|
+
const queueDir = getQueueDir(root);
|
|
190
|
+
const completedDir = join(queueDir, "completed");
|
|
191
|
+
ensureDir(completedDir);
|
|
192
|
+
|
|
193
|
+
const files = readdirSync(queueDir).filter((f) => f.endsWith(".md"));
|
|
194
|
+
const results = [];
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < files.length; i += concurrency) {
|
|
197
|
+
const batch = files.slice(i, i + concurrency);
|
|
198
|
+
await Promise.all(
|
|
199
|
+
batch.map(async (file) => {
|
|
200
|
+
const srcPath = join(queueDir, file);
|
|
201
|
+
try {
|
|
202
|
+
const content = readFileSync(srcPath, "utf-8");
|
|
203
|
+
const task = { title: file, prompt: content };
|
|
204
|
+
const session = await dispatch(task, { root, config, dryRun: opts.dryRun });
|
|
205
|
+
const dstPath = join(completedDir, file);
|
|
206
|
+
renameSync(srcPath, dstPath);
|
|
207
|
+
appendLedger({ event: "task_completed", file, session }, root);
|
|
208
|
+
results.push({ file, ok: true, session });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
results.push({ file, ok: false, error: err.message });
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { processed: results.length, results };
|
|
217
|
+
}
|
package/src/envelope.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { git, resolveBase } from "./git.mjs";
|
|
2
|
+
import { checkScope } from "./security.mjs";
|
|
3
|
+
import { normalizeScope } from "./config.mjs";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validates a Task Envelope before dispatch or during CI triage.
|
|
9
|
+
* Prevents execution on fabricated premises (missing files/tests) or stale bases.
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} envelope - Task envelope payload
|
|
12
|
+
* @param {Object} [opts] - Options (root, maxBehindCommits, maxBaseAgeHours)
|
|
13
|
+
* @returns {{ ok: boolean, code?: number, errors: string[], warnings: string[] }}
|
|
14
|
+
*/
|
|
15
|
+
export function validateEnvelope(envelope = {}, opts = {}) {
|
|
16
|
+
const root = opts.root || process.cwd();
|
|
17
|
+
const maxBehind = opts.maxBehindCommits ?? 25;
|
|
18
|
+
const maxAgeHours = opts.maxBaseAgeHours ?? 24;
|
|
19
|
+
const errors = [];
|
|
20
|
+
const warnings = [];
|
|
21
|
+
|
|
22
|
+
if (!envelope || typeof envelope !== "object") {
|
|
23
|
+
return { ok: false, code: 1, errors: ["Envelope must be a non-null object"], warnings: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 1. Mandatory Fields
|
|
27
|
+
if (!envelope.intent || typeof envelope.intent !== "string" || !envelope.intent.trim()) {
|
|
28
|
+
errors.push("Envelope missing required 'intent' string.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 2. Base Commit & Freshness Check
|
|
32
|
+
const baseCommit = envelope.base_commit || "main";
|
|
33
|
+
let resolvedBase = null;
|
|
34
|
+
try {
|
|
35
|
+
resolvedBase = resolveBase(root, baseCommit);
|
|
36
|
+
} catch (_) {
|
|
37
|
+
try {
|
|
38
|
+
resolvedBase = resolveBase(root, "HEAD");
|
|
39
|
+
} catch (_) {
|
|
40
|
+
resolvedBase = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (resolvedBase) {
|
|
45
|
+
try {
|
|
46
|
+
const behindStr = git(["rev-list", "--count", `${resolvedBase}..origin/main`], { cwd: root, ignoreError: true });
|
|
47
|
+
const behindCount = parseInt(behindStr || "0", 10);
|
|
48
|
+
if (!isNaN(behindCount) && behindCount > maxBehind) {
|
|
49
|
+
errors.push(`Stale base commit: base is ${behindCount} commits behind origin/main (max allowed: ${maxBehind}).`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const commitTimeStr = git(["show", "-s", "--format=%ct", resolvedBase], { cwd: root, ignoreError: true });
|
|
53
|
+
const commitTimestamp = parseInt(commitTimeStr || "0", 10);
|
|
54
|
+
if (commitTimestamp > 0) {
|
|
55
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
56
|
+
const ageHours = (nowSec - commitTimestamp) / 3600;
|
|
57
|
+
if (ageHours > maxAgeHours) {
|
|
58
|
+
warnings.push(`Base commit is ${Math.round(ageHours)}h old (exceeds recommended ${maxAgeHours}h).`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (_) {
|
|
62
|
+
// Git commit range checks are non-blocking if origin/main is not fetched locally
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. Premise Check: Referenced Paths Must Exist
|
|
67
|
+
if (Array.isArray(envelope.referenced_paths)) {
|
|
68
|
+
for (const relPath of envelope.referenced_paths) {
|
|
69
|
+
if (typeof relPath !== "string") continue;
|
|
70
|
+
const absPath = join(root, relPath);
|
|
71
|
+
let existsInGit = false;
|
|
72
|
+
if (resolvedBase) {
|
|
73
|
+
const catRes = git(["cat-file", "-e", `${resolvedBase}:${relPath}`], { cwd: root, ignoreError: true });
|
|
74
|
+
existsInGit = typeof catRes === "string" && catRes.trim() !== "";
|
|
75
|
+
}
|
|
76
|
+
const existsOnDisk = existsSync(absPath);
|
|
77
|
+
if (!existsInGit && !existsOnDisk) {
|
|
78
|
+
errors.push(`Premise failure: referenced path '${relPath}' does not exist at base '${baseCommit}' or on disk.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4. Allowed Paths & Protected Scope Check
|
|
84
|
+
if (Array.isArray(envelope.allowed_paths) && envelope.allowed_paths.length > 0) {
|
|
85
|
+
const scope = normalizeScope(opts.scopeConfig || {});
|
|
86
|
+
const scopeCheck = checkScope(envelope.allowed_paths, scope);
|
|
87
|
+
if (!scopeCheck.ok) {
|
|
88
|
+
errors.push(`Allowed paths violate protected scope: ${scopeCheck.violations.map(v => `${v.file} (${v.reason})`).join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 5. Acceptance Criteria
|
|
93
|
+
if (envelope.acceptance_criteria !== undefined) {
|
|
94
|
+
if (!Array.isArray(envelope.acceptance_criteria) || envelope.acceptance_criteria.length === 0) {
|
|
95
|
+
errors.push("acceptance_criteria must be a non-empty array of strings when provided.");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
ok: errors.length === 0,
|
|
101
|
+
code: errors.length === 0 ? 0 : 1,
|
|
102
|
+
errors,
|
|
103
|
+
warnings,
|
|
104
|
+
};
|
|
105
|
+
}
|