jules-orchestrator-kit 0.9.3 → 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/README.md +69 -263
- package/bin/agentctl.mjs +9 -2
- package/bin/mcp-server.mjs +5 -0
- package/index.mjs +11 -2
- package/package.json +4 -2
- package/scripts/jules-merge-swarm.mjs +179 -11
- package/src/config.mjs +34 -0
- package/src/engine.mjs +119 -3
- package/src/execution_envelope.mjs +103 -0
- package/src/git.mjs +20 -2
- package/src/mcp.mjs +297 -0
- package/src/process-group.mjs +80 -0
- package/src/risk.mjs +3 -2
- package/src/rules_budget.mjs +102 -0
- package/src/security.mjs +45 -6
- package/src/state.mjs +169 -24
- package/src/webhook.mjs +17 -3
|
@@ -1,30 +1,198 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Swarm branch merger with 3-way structural JSON/object merge, git merge-file execution
|
|
5
|
+
* isolated in os.tmpdir(), and safety gate checking.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import { existsSync, readdirSync,
|
|
8
|
+
import { writeFileSync, readFileSync, existsSync, readdirSync, rmSync, mkdtempSync } from "node:fs";
|
|
8
9
|
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { classifyRiskTier, RISK_TIERS } from "../src/risk.mjs";
|
|
13
|
+
import { changedFiles } from "../src/git.mjs";
|
|
9
14
|
|
|
15
|
+
export const EXIT = Object.freeze({
|
|
16
|
+
SUCCESS: 0,
|
|
17
|
+
R3_RESTRICTED: 3,
|
|
18
|
+
MERGE_CONFLICT: 4,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
22
|
+
|
|
23
|
+
function isPlainObject(v) {
|
|
24
|
+
return (
|
|
25
|
+
v !== null &&
|
|
26
|
+
typeof v === "object" &&
|
|
27
|
+
!Array.isArray(v) &&
|
|
28
|
+
(Object.getPrototypeOf(v) === null || Object.getPrototypeOf(v) === Object.prototype)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function deepClone(v) {
|
|
33
|
+
if (v === undefined) return undefined;
|
|
34
|
+
return JSON.parse(JSON.stringify(v));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stableStringify(v) {
|
|
38
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v);
|
|
39
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
40
|
+
const keys = Object.keys(v).filter((k) => !BLOCKED_KEYS.has(k)).sort();
|
|
41
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(",")}}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function deepEqual(a, b) {
|
|
45
|
+
return stableStringify(a) === stableStringify(b);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Recursive 3-way structural object & array merge.
|
|
50
|
+
*/
|
|
51
|
+
export function deepMerge3Way(base, ours, theirs, path = "$") {
|
|
52
|
+
const conflicts = [];
|
|
53
|
+
if (deepEqual(ours, theirs)) return { merged: deepClone(ours), conflicts };
|
|
54
|
+
if (deepEqual(ours, base)) return { merged: deepClone(theirs), conflicts };
|
|
55
|
+
if (deepEqual(theirs, base)) return { merged: deepClone(ours), conflicts };
|
|
56
|
+
|
|
57
|
+
if (isPlainObject(base) && isPlainObject(ours) && isPlainObject(theirs)) {
|
|
58
|
+
const merged = {};
|
|
59
|
+
const safeBase = base || {};
|
|
60
|
+
const safeOurs = ours || {};
|
|
61
|
+
const safeTheirs = theirs || {};
|
|
62
|
+
const keys = new Set([
|
|
63
|
+
...Object.keys(safeBase),
|
|
64
|
+
...Object.keys(safeOurs),
|
|
65
|
+
...Object.keys(safeTheirs),
|
|
66
|
+
]);
|
|
67
|
+
for (const k of keys) {
|
|
68
|
+
if (BLOCKED_KEYS.has(k)) continue;
|
|
69
|
+
const childPath = `${path}.${k}`;
|
|
70
|
+
const b = safeBase[k], o = safeOurs[k], t = safeTheirs[k];
|
|
71
|
+
if (o === undefined && t === undefined) continue;
|
|
72
|
+
if (o === undefined) { merged[k] = deepClone(t); continue; }
|
|
73
|
+
if (t === undefined) { merged[k] = deepClone(o); continue; }
|
|
74
|
+
const sub = deepMerge3Way(b, o, t, childPath);
|
|
75
|
+
merged[k] = sub.merged;
|
|
76
|
+
conflicts.push(...sub.conflicts);
|
|
77
|
+
}
|
|
78
|
+
return { merged, conflicts };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (Array.isArray(ours) && Array.isArray(theirs)) {
|
|
82
|
+
const baseArr = Array.isArray(base) ? base : [];
|
|
83
|
+
const minLen = Math.min(ours.length, theirs.length);
|
|
84
|
+
const merged = [];
|
|
85
|
+
for (let i = 0; i < minLen; i++) {
|
|
86
|
+
const sub = deepMerge3Way(baseArr[i], ours[i], theirs[i], `${path}[${i}]`);
|
|
87
|
+
merged.push(sub.merged);
|
|
88
|
+
conflicts.push(...sub.conflicts);
|
|
89
|
+
}
|
|
90
|
+
if (ours.length > minLen && theirs.length <= minLen) {
|
|
91
|
+
merged.push(...ours.slice(minLen).map(deepClone));
|
|
92
|
+
} else if (theirs.length > minLen && ours.length <= minLen) {
|
|
93
|
+
merged.push(...theirs.slice(minLen).map(deepClone));
|
|
94
|
+
} else if (ours.length > minLen && theirs.length > minLen) {
|
|
95
|
+
conflicts.push({
|
|
96
|
+
path: `${path}[tail]`,
|
|
97
|
+
base: baseArr.slice(minLen),
|
|
98
|
+
ours: ours.slice(minLen),
|
|
99
|
+
theirs: theirs.slice(minLen),
|
|
100
|
+
});
|
|
101
|
+
merged.push(...ours.slice(minLen).map(deepClone));
|
|
102
|
+
}
|
|
103
|
+
return { merged, conflicts };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
conflicts.push({ path, base: deepClone(base), ours: deepClone(ours), theirs: deepClone(theirs) });
|
|
107
|
+
return { merged: deepClone(ours), conflicts };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Executes a function inside a temporary directory in os.tmpdir() with guaranteed cleanup.
|
|
112
|
+
*/
|
|
113
|
+
export function withTempDir(prefix, fn) {
|
|
114
|
+
const dir = mkdtempSync(join(tmpdir(), prefix || "jules-merge-"));
|
|
115
|
+
try {
|
|
116
|
+
return fn(dir);
|
|
117
|
+
} finally {
|
|
118
|
+
try {
|
|
119
|
+
rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
} catch (_) {}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Attempts a 3-way text file merge using git merge-file inside os.tmpdir().
|
|
126
|
+
*/
|
|
127
|
+
export function attemptCodeMergeFile(repoRoot, relPath, oursContent, baseContent, theirsContent) {
|
|
128
|
+
return withTempDir("jules-merge-", (tmp) => {
|
|
129
|
+
const baseFile = join(tmp, "base");
|
|
130
|
+
const oursFile = join(tmp, "ours");
|
|
131
|
+
const theirsFile = join(tmp, "theirs");
|
|
132
|
+
writeFileSync(baseFile, baseContent ?? "", "utf-8");
|
|
133
|
+
writeFileSync(oursFile, oursContent ?? "", "utf-8");
|
|
134
|
+
writeFileSync(theirsFile, theirsContent ?? "", "utf-8");
|
|
135
|
+
|
|
136
|
+
const res = spawnSync("git", ["merge-file", "-p", "--diff3", oursFile, baseFile, theirsFile], {
|
|
137
|
+
encoding: "utf-8",
|
|
138
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (res.error) {
|
|
142
|
+
return { ok: false, conflicts: 0, error: res.error.message };
|
|
143
|
+
}
|
|
144
|
+
const conflictCount = res.status && res.status > 0 ? res.status : 0;
|
|
145
|
+
const merged = res.stdout ?? "";
|
|
146
|
+
return {
|
|
147
|
+
ok: conflictCount === 0,
|
|
148
|
+
merged,
|
|
149
|
+
conflicts: conflictCount,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Checks safety gate against active worker locks and risk tiers.
|
|
156
|
+
*/
|
|
10
157
|
export function checkSafetyGate(branchName = "", projectRoot = process.cwd()) {
|
|
11
158
|
const locksDir = join(projectRoot, ".agent/state/locks");
|
|
12
|
-
if (
|
|
159
|
+
if (existsSync(locksDir)) {
|
|
160
|
+
try {
|
|
161
|
+
const files = readdirSync(locksDir);
|
|
162
|
+
for (const f of files) {
|
|
163
|
+
if (!f.endsWith(".json")) continue;
|
|
164
|
+
const content = readFileSync(join(locksDir, f), "utf-8");
|
|
165
|
+
const parsed = JSON.parse(content);
|
|
166
|
+
if (parsed.branch === branchName) {
|
|
167
|
+
return { safe: false, reason: `Active lock held by worker ${parsed.agent || "unknown"}` };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch (_) {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check R3 risk tier if files modified
|
|
13
174
|
try {
|
|
14
|
-
const files =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (parsed.branch === branchName) {
|
|
20
|
-
return { safe: false, reason: `Active lock held by worker ${parsed.agent || "unknown"}` };
|
|
175
|
+
const files = changedFiles(projectRoot, process.env.BASE_BRANCH || "main");
|
|
176
|
+
if (files.length > 0) {
|
|
177
|
+
const tier = classifyRiskTier(files);
|
|
178
|
+
if (tier.tier === RISK_TIERS.R3) {
|
|
179
|
+
return { safe: false, reason: `R3 Restricted Path violation: ${tier.reason}` };
|
|
21
180
|
}
|
|
22
181
|
}
|
|
23
182
|
} catch (_) {}
|
|
183
|
+
|
|
24
184
|
return { safe: true };
|
|
25
185
|
}
|
|
26
186
|
|
|
27
187
|
if (process.argv[1] && process.argv[1].endsWith("jules-merge-swarm.mjs")) {
|
|
188
|
+
const branchName = process.argv[2] || "";
|
|
189
|
+
const gateResult = checkSafetyGate(branchName);
|
|
190
|
+
if (!gateResult.safe) {
|
|
191
|
+
console.error(`[merge-swarm] Safety gate check failed: ${gateResult.reason}`);
|
|
192
|
+
process.exit(EXIT.R3_RESTRICTED);
|
|
193
|
+
}
|
|
194
|
+
|
|
28
195
|
console.log("No open Jules PRs found.");
|
|
29
|
-
process.exit(
|
|
196
|
+
process.exit(EXIT.SUCCESS);
|
|
30
197
|
}
|
|
198
|
+
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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}`,
|
|
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}`], {
|