jules-orchestrator-kit 0.38.2 → 0.41.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/.agent/prompts/Bolt.md +10 -7
- package/.agent/prompts/Janitor.md +6 -6
- package/.agent/prompts/Task_Template.md +6 -5
- package/.agent/rules/jules-protocol.md +1 -1
- package/JULES_RULES_TEMPLATE.md +12 -8
- package/README.md +45 -8
- package/bin/agentctl.mjs +297 -9
- package/index.mjs +15 -1
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +186 -0
- package/scripts/jules-merge-swarm.mjs +13 -3
- package/src/assertions.mjs +579 -0
- package/src/budget.mjs +48 -6
- package/src/config.mjs +69 -6
- package/src/dag-engine.mjs +22 -1
- package/src/engine.mjs +133 -23
- package/src/evidence.mjs +111 -2
- package/src/execution-envelope.mjs +23 -7
- package/src/flaky-ledger.mjs +4 -1
- package/src/ops/command-registry.mjs +32 -0
- package/src/ops/doctor-planner.mjs +9 -3
- package/src/ops/pr-harvest.mjs +349 -0
- package/src/provider.mjs +491 -25
- package/src/review-repair.mjs +85 -7
- package/src/risk.mjs +134 -26
- package/src/role-resolver.mjs +54 -2
- package/src/router.mjs +64 -6
- package/src/state.mjs +15 -3
- package/src/task-optimizer.mjs +9 -1
- package/src/telemetry.mjs +65 -0
- package/src/web-templates.mjs +372 -7
- package/src/wizard-init.mjs +13 -3
- package/src/wizard-task.mjs +76 -6
package/src/config.mjs
CHANGED
|
@@ -155,6 +155,12 @@ export function normalizeScope(parsed = {}) {
|
|
|
155
155
|
*/
|
|
156
156
|
export function parseYaml(src) {
|
|
157
157
|
if (!src || typeof src !== "string") return Object.create(null);
|
|
158
|
+
const trimmedSrc = src.trim();
|
|
159
|
+
if (trimmedSrc.startsWith("{") && trimmedSrc.endsWith("}")) {
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(trimmedSrc);
|
|
162
|
+
} catch (_) {}
|
|
163
|
+
}
|
|
158
164
|
const root = Object.create(null);
|
|
159
165
|
const stack = [{ indent: -1, node: root }];
|
|
160
166
|
const lines = src.split("\n");
|
|
@@ -174,13 +180,44 @@ export function parseYaml(src) {
|
|
|
174
180
|
const top = stack[stack.length - 1];
|
|
175
181
|
|
|
176
182
|
if (trimmed.startsWith("- ")) {
|
|
183
|
+
const itemBody = trimmed.slice(2).trim();
|
|
184
|
+
const colonIdx = itemBody.indexOf(":");
|
|
185
|
+
if (colonIdx > 0 && !itemBody.startsWith('"') && !itemBody.startsWith("'") && !itemBody.startsWith("[")) {
|
|
186
|
+
const itemKey = itemBody.slice(0, colonIdx).trim();
|
|
187
|
+
const itemVal = itemBody.slice(colonIdx + 1).trim();
|
|
188
|
+
if (BLOCKED_KEYS.has(itemKey)) {
|
|
189
|
+
throw new ConfigError(`Illegal prototype key "${itemKey}" detected in configuration`);
|
|
190
|
+
}
|
|
191
|
+
const obj = Object.create(null);
|
|
192
|
+
if (itemVal.startsWith("[") && itemVal.endsWith("]")) {
|
|
193
|
+
obj[itemKey] = itemVal
|
|
194
|
+
.slice(1, -1)
|
|
195
|
+
.split(",")
|
|
196
|
+
.map((s) => coerce(s.trim()))
|
|
197
|
+
.filter((s) => s !== "");
|
|
198
|
+
} else if (itemVal !== "") {
|
|
199
|
+
obj[itemKey] = coerce(itemVal);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (top.key && top.parent) {
|
|
203
|
+
if (!Array.isArray(top.parent[top.key])) {
|
|
204
|
+
top.parent[top.key] = [];
|
|
205
|
+
}
|
|
206
|
+
top.parent[top.key].push(obj);
|
|
207
|
+
} else if (Array.isArray(top.node)) {
|
|
208
|
+
top.node.push(obj);
|
|
209
|
+
}
|
|
210
|
+
stack.push({ indent, node: obj, isArrayItem: true });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
177
214
|
if (top.key && top.parent) {
|
|
178
215
|
if (!Array.isArray(top.parent[top.key])) {
|
|
179
216
|
top.parent[top.key] = [];
|
|
180
217
|
}
|
|
181
|
-
top.parent[top.key].push(coerce(
|
|
218
|
+
top.parent[top.key].push(coerce(itemBody));
|
|
182
219
|
} else if (Array.isArray(top.node)) {
|
|
183
|
-
top.node.push(coerce(
|
|
220
|
+
top.node.push(coerce(itemBody));
|
|
184
221
|
}
|
|
185
222
|
continue;
|
|
186
223
|
}
|
|
@@ -357,8 +394,21 @@ export const TIER_PRESETS = {
|
|
|
357
394
|
/** Tier names that correspond to real vendor plans, in ascending order. */
|
|
358
395
|
export const VENDOR_TIERS = ["free", "pro", "ultra"];
|
|
359
396
|
|
|
360
|
-
/**
|
|
361
|
-
|
|
397
|
+
/**
|
|
398
|
+
* The tier assumed when a repository states none, or names one that does not exist.
|
|
399
|
+
*
|
|
400
|
+
* `free` rather than `ultra`, because this is a guess about someone else's
|
|
401
|
+
* billing account and the two directions fail very differently. Guessing low
|
|
402
|
+
* costs a Pro or Ultra operator some throughput until they set `tier:` — a
|
|
403
|
+
* number they see in `agentctl doctor` and can raise in one line. Guessing high
|
|
404
|
+
* hands a Free account a 15-worker swarm and a 300-task allowance it does not
|
|
405
|
+
* have, so the first real run dispatches into `FAILED_PRECONDITION` quota
|
|
406
|
+
* rejections, which is also the worst possible first impression of the kit.
|
|
407
|
+
*
|
|
408
|
+
* The learned-ceiling logic in src/budget.mjs corrects upward from observation;
|
|
409
|
+
* nothing corrects downward from an over-generous assumption.
|
|
410
|
+
*/
|
|
411
|
+
export const FALLBACK_TIER = "free";
|
|
362
412
|
|
|
363
413
|
/**
|
|
364
414
|
* Escalation reasons that bypass the Silence Governor and alert immediately.
|
|
@@ -406,8 +456,8 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
406
456
|
const verifyTimeoutMs = parsed.verify?.timeoutMs ?? parsed.verify?.timeout_ms ?? 60000;
|
|
407
457
|
const autoVerify = resolveVerify(root);
|
|
408
458
|
|
|
409
|
-
const rawTier = String(process.env.JULES_TIER || parsed.tier ||
|
|
410
|
-
const activeTier = TIER_PRESETS[rawTier] ? rawTier :
|
|
459
|
+
const rawTier = String(process.env.JULES_TIER || parsed.tier || FALLBACK_TIER).toLowerCase();
|
|
460
|
+
const activeTier = TIER_PRESETS[rawTier] ? rawTier : FALLBACK_TIER;
|
|
411
461
|
const tierLimits = TIER_PRESETS[activeTier];
|
|
412
462
|
|
|
413
463
|
const envDailyTasks = process.env.JULES_DAILY_BUDGET !== undefined && Number.isFinite(Number(process.env.JULES_DAILY_BUDGET)) ? Number(process.env.JULES_DAILY_BUDGET) : null;
|
|
@@ -470,6 +520,18 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
470
520
|
discordWebhookUrl: parsed.notifications?.discord_webhook_url || parsed.notifications?.discordWebhookUrl || "",
|
|
471
521
|
},
|
|
472
522
|
scope: normalizeScope(parsed),
|
|
523
|
+
// Project-specific risk paths. These extend the builtin lists in risk.mjs
|
|
524
|
+
// rather than replacing them; see resolveRiskPatterns() for why. This is
|
|
525
|
+
// where a repository declares the directories that are dangerous *for it* —
|
|
526
|
+
// billing, tax rates, pricing, smart contracts — which cannot be guessed
|
|
527
|
+
// from the stack and must not be shipped as someone else's defaults.
|
|
528
|
+
risk: {
|
|
529
|
+
restricted: Array.isArray(parsed.risk?.restricted) ? parsed.risk.restricted : [],
|
|
530
|
+
consequential: Array.isArray(parsed.risk?.consequential) ? parsed.risk.consequential : [],
|
|
531
|
+
maxRoutineDiffLines: Number.isFinite(Number(parsed.risk?.max_routine_diff_lines ?? parsed.risk?.maxRoutineDiffLines))
|
|
532
|
+
? Number(parsed.risk.max_routine_diff_lines ?? parsed.risk.maxRoutineDiffLines)
|
|
533
|
+
: 400,
|
|
534
|
+
},
|
|
473
535
|
limits: {
|
|
474
536
|
...DEFAULTS.limits,
|
|
475
537
|
...tierLimits,
|
|
@@ -494,6 +556,7 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
494
556
|
runner: parsed.runner || DEFAULTS.runner,
|
|
495
557
|
branchPrefix: parsed.branch_prefix || parsed.branchPrefix || DEFAULTS.branchPrefix,
|
|
496
558
|
baseBranch: parsed.base_branch || parsed.baseBranch || DEFAULTS.baseBranch,
|
|
559
|
+
source: parsed.source || process.env.JULES_REPO || "",
|
|
497
560
|
julesApiKeys: Array.from(new Set([
|
|
498
561
|
(process.env.JULES_API_KEY || "").trim(),
|
|
499
562
|
...(process.env.JULES_API_KEYS || process.env.JULES_API_KEY_SECONDARY || "").split(",").map((k) => k.trim()),
|
package/src/dag-engine.mjs
CHANGED
|
@@ -493,6 +493,8 @@ export async function executeQueueDag(root = process.cwd(), options = {}) {
|
|
|
493
493
|
let role = undefined;
|
|
494
494
|
let tier = undefined;
|
|
495
495
|
|
|
496
|
+
let targetFiles = [];
|
|
497
|
+
|
|
496
498
|
// Check envelope header
|
|
497
499
|
const match = content.match(/<!--\s*JULES_TASK_ENVELOPE:\s*({[\s\S]*?})\s*-->/);
|
|
498
500
|
if (match) {
|
|
@@ -502,6 +504,8 @@ export async function executeQueueDag(root = process.cwd(), options = {}) {
|
|
|
502
504
|
if (meta.title) title = meta.title;
|
|
503
505
|
if (meta.role) role = meta.role;
|
|
504
506
|
if (meta.tier) tier = meta.tier;
|
|
507
|
+
if (Array.isArray(meta.targetFiles)) targetFiles = meta.targetFiles;
|
|
508
|
+
else if (Array.isArray(meta.referenced_paths)) targetFiles = meta.referenced_paths;
|
|
505
509
|
if (Array.isArray(meta.dependsOn)) dependsOn = meta.dependsOn;
|
|
506
510
|
else if (typeof meta.dependsOn === "string") dependsOn = meta.dependsOn.split(",").map((s) => s.trim()).filter(Boolean);
|
|
507
511
|
} catch (_) {}
|
|
@@ -513,12 +517,29 @@ export async function executeQueueDag(root = process.cwd(), options = {}) {
|
|
|
513
517
|
if (parsed.prompt) prompt = parsed.prompt;
|
|
514
518
|
if (parsed.role) role = parsed.role;
|
|
515
519
|
if (parsed.tier) tier = parsed.tier;
|
|
520
|
+
if (Array.isArray(parsed.targetFiles)) targetFiles = parsed.targetFiles;
|
|
521
|
+
else if (Array.isArray(parsed.referenced_paths)) targetFiles = parsed.referenced_paths;
|
|
516
522
|
if (Array.isArray(parsed.dependsOn)) dependsOn = parsed.dependsOn;
|
|
517
523
|
else if (typeof parsed.dependsOn === "string") dependsOn = parsed.dependsOn.split(",").map((s) => s.trim()).filter(Boolean);
|
|
518
524
|
} catch (_) {}
|
|
519
525
|
}
|
|
520
526
|
|
|
521
|
-
taskMap.set(taskId, { file, fullPath, taskId, title, prompt, role, tier, dependsOn });
|
|
527
|
+
taskMap.set(taskId, { file, fullPath, taskId, title, prompt, role, tier, dependsOn, targetFiles });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Auto-serialize concurrent tasks that touch overlapping target files to prevent git merge conflicts
|
|
531
|
+
const fileToLastTaskId = new Map();
|
|
532
|
+
for (const [taskId, t] of taskMap.entries()) {
|
|
533
|
+
for (const rawPath of t.targetFiles || []) {
|
|
534
|
+
const norm = String(rawPath).replace(/\\/g, "/");
|
|
535
|
+
if (fileToLastTaskId.has(norm)) {
|
|
536
|
+
const priorTaskId = fileToLastTaskId.get(norm);
|
|
537
|
+
if (priorTaskId !== taskId && !t.dependsOn.includes(priorTaskId)) {
|
|
538
|
+
t.dependsOn.push(priorTaskId);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
fileToLastTaskId.set(norm, taskId);
|
|
542
|
+
}
|
|
522
543
|
}
|
|
523
544
|
|
|
524
545
|
// Register each task with its dependencies that are part of this queue run
|
package/src/engine.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git
|
|
|
4
4
|
import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
|
|
5
5
|
import { resolveRoutedProvider } from "./router.mjs";
|
|
6
6
|
import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked, checkDailyBudget } from "./state.mjs";
|
|
7
|
-
import { resolveDailyLimit, recordObservedCeiling, isDailyQuotaRejection } from "./budget.mjs";
|
|
7
|
+
import { resolveDailyLimit, recordObservedCeiling, isDailyQuotaRejection, resolveAmbientIdentity } from "./budget.mjs";
|
|
8
8
|
import { sanitizeUntrustedData, buildAgentEnvelope } from "./prompt-guard.mjs";
|
|
9
9
|
import { recordVerifyRun, readVerifyRuns, flakyVerdict } from "./flaky-ledger.mjs";
|
|
10
10
|
import fs, { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
|
|
@@ -18,12 +18,14 @@ import { recordRemediation, queryRemediations, harvestFailureRecord, hydrateMemo
|
|
|
18
18
|
import { hydratePrompt, harvestFailure } from "./memory.mjs";
|
|
19
19
|
import { resolveRolePrompt } from "./role-resolver.mjs";
|
|
20
20
|
|
|
21
|
+
import { runAssertion } from "./assertions.mjs";
|
|
21
22
|
import {
|
|
22
23
|
computeDirectoryHash,
|
|
23
24
|
generateEvidenceManifest,
|
|
24
25
|
writeEvidenceManifest,
|
|
25
26
|
verifyEvidenceManifest,
|
|
26
27
|
generateEvidenceMarkdown,
|
|
28
|
+
exportJsonReport,
|
|
27
29
|
sha256,
|
|
28
30
|
} from "./evidence.mjs";
|
|
29
31
|
|
|
@@ -47,6 +49,8 @@ export {
|
|
|
47
49
|
writeEvidenceManifest,
|
|
48
50
|
verifyEvidenceManifest,
|
|
49
51
|
generateEvidenceMarkdown,
|
|
52
|
+
exportJsonReport,
|
|
53
|
+
runAssertion,
|
|
50
54
|
};
|
|
51
55
|
|
|
52
56
|
|
|
@@ -296,34 +300,60 @@ export async function gate(opts = {}) {
|
|
|
296
300
|
|
|
297
301
|
try {
|
|
298
302
|
for (const stage of stagesToRun) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
303
|
+
const isAssertion = Boolean(
|
|
304
|
+
stage.assert ||
|
|
305
|
+
(stage.type && String(stage.type).startsWith("assert:")) ||
|
|
306
|
+
(stage.kind && String(stage.kind).startsWith("assert:"))
|
|
307
|
+
);
|
|
308
|
+
if (!stage.cmd && !isAssertion) continue;
|
|
304
309
|
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
+
const startTime = Date.now();
|
|
311
|
+
let res;
|
|
312
|
+
let durationMs;
|
|
313
|
+
let stdoutRedacted;
|
|
314
|
+
let stderrRedacted;
|
|
315
|
+
let stageOk;
|
|
316
|
+
let assertDiagnostics = [];
|
|
317
|
+
let assertMetrics = {};
|
|
318
|
+
|
|
319
|
+
if (isAssertion) {
|
|
320
|
+
const assertRes = runAssertion(stage, root);
|
|
321
|
+
durationMs = assertRes.metrics?.durationMs ?? (Date.now() - startTime);
|
|
322
|
+
stdoutRedacted = assertRes.stdout || "";
|
|
323
|
+
stderrRedacted = assertRes.stderr || "";
|
|
324
|
+
stageOk = assertRes.ok;
|
|
325
|
+
assertDiagnostics = assertRes.diagnostics || [];
|
|
326
|
+
assertMetrics = assertRes.metrics || {};
|
|
327
|
+
res = { status: assertRes.status, stdout: stdoutRedacted, stderr: stderrRedacted };
|
|
328
|
+
} else {
|
|
329
|
+
const stageEnv = stage.networkAccess === "forbidden" || trustedVerify.policy?.offline
|
|
330
|
+
? { ...testEnv, JULES_SANDBOX_OFFLINE: "1" }
|
|
331
|
+
: testEnv;
|
|
332
|
+
|
|
333
|
+
res = runCmd(stage.cmd, { cwd: root, ignoreError: true, env: stageEnv, timeout: stage.timeoutMs || verifyTimeout });
|
|
334
|
+
durationMs = Date.now() - startTime;
|
|
335
|
+
stdoutRedacted = redactSecrets(res.stdout || "");
|
|
336
|
+
stderrRedacted = redactSecrets(res.stderr || "");
|
|
337
|
+
stageOk = res.status === 0;
|
|
338
|
+
}
|
|
310
339
|
|
|
311
340
|
executionRecords.push({
|
|
312
|
-
id: stage.id || stage.kind,
|
|
313
|
-
kind: stage.kind,
|
|
314
|
-
cmd: stage.cmd
|
|
341
|
+
id: stage.id || stage.kind || (isAssertion ? `assert:${stage.assert}` : "stage"),
|
|
342
|
+
kind: stage.kind || (isAssertion ? "assert" : "test"),
|
|
343
|
+
cmd: stage.cmd || `assert:${stage.assert || stage.type || stage.kind}`,
|
|
315
344
|
exitCode: res.status,
|
|
316
345
|
durationMs,
|
|
317
|
-
networkAccess: stage.networkAccess || "allow",
|
|
346
|
+
networkAccess: isAssertion ? "offline" : (stage.networkAccess || "allow"),
|
|
318
347
|
stdoutHash: "sha256:" + sha256(stdoutRedacted),
|
|
319
348
|
stderrHash: "sha256:" + sha256(stderrRedacted),
|
|
349
|
+
...(isAssertion ? { assert: stage.assert || stage.type || stage.kind, diagnostics: assertDiagnostics, metrics: assertMetrics } : {}),
|
|
320
350
|
});
|
|
321
351
|
|
|
322
352
|
if (stage.kind === "test" || stage.kind === "unit") {
|
|
323
353
|
testResult = { ok: stageOk, status: res.status, stdout: stdoutRedacted, stderr: stderrRedacted, command: stage.cmd };
|
|
324
354
|
const fingerprint = !stageOk ? fingerprintFailureState(testResult, root) : null;
|
|
325
|
-
recordVerifyRun(root, stage.cmd, stageOk, fingerprint, durationMs);
|
|
326
|
-
if (!stageOk) {
|
|
355
|
+
if (stage.cmd) recordVerifyRun(root, stage.cmd, stageOk, fingerprint, durationMs);
|
|
356
|
+
if (!stageOk && stage.cmd) {
|
|
327
357
|
const runs = readVerifyRuns(root, stage.cmd);
|
|
328
358
|
flakyVerdictResult = flakyVerdict(runs);
|
|
329
359
|
if (flakyVerdictResult.verdict === "QUARANTINED") {
|
|
@@ -343,8 +373,11 @@ export async function gate(opts = {}) {
|
|
|
343
373
|
status: res.status,
|
|
344
374
|
stdout: stdoutRedacted,
|
|
345
375
|
stderr: stderrRedacted,
|
|
346
|
-
command: stage.cmd
|
|
347
|
-
phase: stage.kind,
|
|
376
|
+
command: stage.cmd || `assert:${stage.assert || stage.type || stage.kind}`,
|
|
377
|
+
phase: stage.kind || (isAssertion ? "assert" : "stage"),
|
|
378
|
+
stageId: stage.id || (isAssertion ? `assert:${stage.assert || stage.type || stage.kind}` : stage.kind),
|
|
379
|
+
diagnostics: assertDiagnostics,
|
|
380
|
+
metrics: assertMetrics,
|
|
348
381
|
};
|
|
349
382
|
break;
|
|
350
383
|
}
|
|
@@ -378,8 +411,11 @@ export async function gate(opts = {}) {
|
|
|
378
411
|
}
|
|
379
412
|
}
|
|
380
413
|
|
|
414
|
+
const verifyOk = !failingCmd && !testTampered;
|
|
415
|
+
|
|
381
416
|
// Generate & persist Evidence Manifest
|
|
382
417
|
const evidenceManifest = generateEvidenceManifest(root, {
|
|
418
|
+
taskId: opts.taskId,
|
|
383
419
|
preTestHash,
|
|
384
420
|
executionRecords,
|
|
385
421
|
secretScanOk: secretResult.ok,
|
|
@@ -387,6 +423,10 @@ export async function gate(opts = {}) {
|
|
|
387
423
|
maxDiffKb: config.limits.diffKb || 75,
|
|
388
424
|
protectedScopeOk: scopeResult.ok,
|
|
389
425
|
repository: config.provider || "jules",
|
|
426
|
+
failedStage: failingCmd?.stageId || failingCmd?.phase || null,
|
|
427
|
+
diagnostics: failingCmd?.diagnostics?.length ? failingCmd.diagnostics : (failingCmd?.stderr ? [failingCmd.stderr] : []),
|
|
428
|
+
metrics: failingCmd?.metrics || {},
|
|
429
|
+
ok: verifyOk,
|
|
390
430
|
});
|
|
391
431
|
if (testTampered) {
|
|
392
432
|
evidenceManifest.testIntegrity.tamperDetected = true;
|
|
@@ -396,7 +436,13 @@ export async function gate(opts = {}) {
|
|
|
396
436
|
evidencePath = writeEvidenceManifest(root, evidenceManifest);
|
|
397
437
|
} catch (_) {}
|
|
398
438
|
|
|
399
|
-
|
|
439
|
+
// Export structured JSON report if requested
|
|
440
|
+
const jsonReportPath = opts.jsonReport || opts.jsonReportPath || process.env.JULES_JSON_REPORT || null;
|
|
441
|
+
if (jsonReportPath) {
|
|
442
|
+
try {
|
|
443
|
+
exportJsonReport(evidenceManifest, jsonReportPath);
|
|
444
|
+
} catch (_) {}
|
|
445
|
+
}
|
|
400
446
|
phases.push({
|
|
401
447
|
phase: "verify",
|
|
402
448
|
ok: verifyOk,
|
|
@@ -526,7 +572,8 @@ export async function repair(failure, opts = {}) {
|
|
|
526
572
|
{ root, dryRun: opts.dryRun }
|
|
527
573
|
),
|
|
528
574
|
root,
|
|
529
|
-
config.limits.dailyTasks
|
|
575
|
+
config.limits.dailyTasks,
|
|
576
|
+
{ author: opts.author ? resolveAmbientIdentity(opts.author) : "ooda-repair-bot" }
|
|
530
577
|
);
|
|
531
578
|
} catch (err) {
|
|
532
579
|
if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
|
|
@@ -622,10 +669,48 @@ export async function repair(failure, opts = {}) {
|
|
|
622
669
|
/**
|
|
623
670
|
* Polls an async provider for terminal session state (COMPLETED / FAILED) before re-verification.
|
|
624
671
|
*/
|
|
672
|
+
/**
|
|
673
|
+
* Evaluates whether a task's verification oracle or goal is already satisfied on the current working tree.
|
|
674
|
+
* Prevents redundant session dispatch and API budget burning.
|
|
675
|
+
*/
|
|
676
|
+
export async function checkTaskPremise(task = {}, opts = {}) {
|
|
677
|
+
const root = opts.config?._root || opts.root || process.cwd();
|
|
678
|
+
const verifyCmd = task.verifyCmd || task.verify;
|
|
679
|
+
if (!verifyCmd) {
|
|
680
|
+
return { satisfied: false, reason: "No verification oracle specified for pre-flight premise check." };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const { runVerificationProbe } = await import("./wizard-oracle.mjs");
|
|
684
|
+
const probe = await runVerificationProbe(verifyCmd, root, { timeoutMs: opts.timeoutMs || 30_000 });
|
|
685
|
+
if (probe.ok) {
|
|
686
|
+
return {
|
|
687
|
+
satisfied: true,
|
|
688
|
+
reason: `Verification oracle '${verifyCmd}' already passes cleanly with exit code 0 on base branch.`,
|
|
689
|
+
durationMs: probe.durationMs,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
satisfied: false,
|
|
695
|
+
reason: `Verification oracle '${verifyCmd}' failed (exit ${probe.code}), proving task need.`,
|
|
696
|
+
durationMs: probe.durationMs,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Polls the provider until the session terminates in COMPLETED, FAILED, or reaches timeout.
|
|
702
|
+
*/
|
|
625
703
|
export async function pollSessionState(provider, session, opts = {}) {
|
|
626
704
|
if (!session || !session.id) return { status: "COMPLETED" };
|
|
627
705
|
const initialStatus = String(session.status || session.state || "").toUpperCase();
|
|
628
|
-
if (
|
|
706
|
+
if (
|
|
707
|
+
initialStatus === "COMPLETED" ||
|
|
708
|
+
initialStatus === "FAILED" ||
|
|
709
|
+
opts.dryRun ||
|
|
710
|
+
session.id === "dry-run-session-id" ||
|
|
711
|
+
session.id.startsWith("mock-") ||
|
|
712
|
+
session.id.startsWith("dry-run-")
|
|
713
|
+
) {
|
|
629
714
|
return { status: initialStatus || "COMPLETED" };
|
|
630
715
|
}
|
|
631
716
|
|
|
@@ -650,6 +735,17 @@ export async function pollSessionState(provider, session, opts = {}) {
|
|
|
650
735
|
|
|
651
736
|
if (currentSession) {
|
|
652
737
|
const status = String(currentSession.status || currentSession.state || "").toUpperCase();
|
|
738
|
+
if (
|
|
739
|
+
(status === "AWAITING_PLAN_APPROVAL" || status === "PENDING_APPROVAL") &&
|
|
740
|
+
(opts.autoApprovePlan || opts.autoApprove || session.autoApprovePlan)
|
|
741
|
+
) {
|
|
742
|
+
if (provider && typeof provider.approvePlan === "function") {
|
|
743
|
+
try {
|
|
744
|
+
await provider.approvePlan(session.id, opts);
|
|
745
|
+
} catch (_) {}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
653
749
|
if (status === "COMPLETED" || status === "FAILED") {
|
|
654
750
|
return { ...currentSession, status };
|
|
655
751
|
}
|
|
@@ -717,6 +813,19 @@ export async function dispatch(task = {}, opts = {}) {
|
|
|
717
813
|
throw new Error(`Task prompt exceeds maximum payload limit of ${config.limits.promptKb} KB`);
|
|
718
814
|
}
|
|
719
815
|
|
|
816
|
+
// Pre-flight idempotency premise check
|
|
817
|
+
if (opts.checkPremise || task.checkPremise) {
|
|
818
|
+
const premise = await checkTaskPremise(task, { root, config });
|
|
819
|
+
if (premise.satisfied) {
|
|
820
|
+
return {
|
|
821
|
+
id: "premise-already-satisfied",
|
|
822
|
+
status: "ALREADY_SATISFIED",
|
|
823
|
+
skipped: true,
|
|
824
|
+
reason: premise.reason,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
720
829
|
// Redact secrets in prompt before dispatching
|
|
721
830
|
let cleanPrompt = redactSecrets(task.prompt || "");
|
|
722
831
|
|
|
@@ -765,9 +874,10 @@ export async function dispatch(task = {}, opts = {}) {
|
|
|
765
874
|
// consume one of the operator's finite daily task slots. Reserving here also
|
|
766
875
|
// made every `npm test` burn real budget, which eventually exhausted the
|
|
767
876
|
// ledger and turned the suite red for reasons unrelated to any code change.
|
|
877
|
+
const author = resolveAmbientIdentity(task.author || opts.author || null);
|
|
768
878
|
const session = opts.dryRun
|
|
769
879
|
? await runDispatch()
|
|
770
|
-
: await withBudget(runDispatch, root, budget.limit, { enforce: budget.certain });
|
|
880
|
+
: await withBudget(runDispatch, root, budget.limit, { enforce: budget.certain, author });
|
|
771
881
|
return classification ? { ...session, _routeTier: classification.tier, _routeReason: classification.reason } : session;
|
|
772
882
|
} catch (err) {
|
|
773
883
|
// A refusal for daily quota is the only authoritative statement of the
|
package/src/evidence.mjs
CHANGED
|
@@ -156,6 +156,11 @@ export function computeEvidenceHash(manifest) {
|
|
|
156
156
|
testIntegrity: manifest.testIntegrity,
|
|
157
157
|
executionRecords: manifest.executionRecords,
|
|
158
158
|
securityChecks: manifest.securityChecks,
|
|
159
|
+
...(manifest.status ? { status: manifest.status } : {}),
|
|
160
|
+
...(manifest.failedStage ? { failedStage: manifest.failedStage } : {}),
|
|
161
|
+
...(manifest.diagnostics && manifest.diagnostics.length > 0 ? { diagnostics: manifest.diagnostics } : {}),
|
|
162
|
+
...(manifest.metrics && Object.keys(manifest.metrics).length > 0 ? { metrics: manifest.metrics } : {}),
|
|
163
|
+
...(manifest.summary && Object.keys(manifest.summary).length > 0 ? { summary: manifest.summary } : {}),
|
|
159
164
|
});
|
|
160
165
|
return "sha256:" + sha256(canonicalPayload);
|
|
161
166
|
}
|
|
@@ -242,11 +247,18 @@ export function generateEvidenceManifest(root = process.cwd(), options = {}) {
|
|
|
242
247
|
const promptHash = taskPrompt ? "sha256:" + sha256(taskPrompt) : "sha256:empty";
|
|
243
248
|
const promptExcerpt = taskPrompt ? taskPrompt.slice(0, 140).replace(/[\r\n]+/g, " ") : "";
|
|
244
249
|
|
|
250
|
+
const diagnostics = Array.isArray(options.diagnostics) ? options.diagnostics : [];
|
|
251
|
+
const metrics = options.metrics || {};
|
|
252
|
+
const status = options.status || (options.ok === false || tamperDetected || diagnostics.length > 0 ? "failed" : "passed");
|
|
253
|
+
const failedStage = options.failedStage || options.failedStep || null;
|
|
254
|
+
|
|
245
255
|
// Base manifest
|
|
246
256
|
const baseManifest = {
|
|
247
257
|
schema: "agentctl/evidence-manifest-v1",
|
|
248
258
|
manifestId,
|
|
249
259
|
generatedAt,
|
|
260
|
+
status,
|
|
261
|
+
...(failedStage ? { failedStage } : {}),
|
|
250
262
|
intent: {
|
|
251
263
|
taskId: options.taskId || options.task?.id || "local-task",
|
|
252
264
|
title: options.title || options.task?.title || "Verification Run",
|
|
@@ -273,6 +285,8 @@ export function generateEvidenceManifest(root = process.cwd(), options = {}) {
|
|
|
273
285
|
maxDiffKb: options.maxDiffKb || 75,
|
|
274
286
|
protectedScopeOk: options.protectedScopeOk ?? true,
|
|
275
287
|
},
|
|
288
|
+
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
289
|
+
...(Object.keys(metrics).length > 0 ? { metrics } : {}),
|
|
276
290
|
};
|
|
277
291
|
|
|
278
292
|
const evidenceHash = computeEvidenceHash(baseManifest);
|
|
@@ -307,9 +321,70 @@ export function writeEvidenceManifest(root, manifest, customPath = null) {
|
|
|
307
321
|
writeFileAtomically(latestPath, JSON.stringify(manifest, null, 2));
|
|
308
322
|
} catch (_) {}
|
|
309
323
|
|
|
324
|
+
// Written at a custom path means the caller owns the location and its
|
|
325
|
+
// lifetime; only the kit's own directory is ours to rotate.
|
|
326
|
+
if (!customPath) {
|
|
327
|
+
pruneEvidenceManifests(root);
|
|
328
|
+
}
|
|
329
|
+
|
|
310
330
|
return targetPath;
|
|
311
331
|
}
|
|
312
332
|
|
|
333
|
+
/**
|
|
334
|
+
* How many evidence manifests to keep.
|
|
335
|
+
*
|
|
336
|
+
* Two are written per verified task, so this is roughly the last hundred runs.
|
|
337
|
+
* Checkpoints (10) and handovers (20) already rotate; evidence and telemetry
|
|
338
|
+
* did not, and a fortnight of ordinary use left 305 files and 2.6 MB in a
|
|
339
|
+
* directory nothing ever read past the newest few. Unbounded growth inside
|
|
340
|
+
* `.agent/` is not just disk: `getLedgerPathsInWindow` and the doctor checks
|
|
341
|
+
* walk these directories, so every dispatch slowly pays for every run before it.
|
|
342
|
+
*/
|
|
343
|
+
export const EVIDENCE_RETENTION = 200;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Removes the oldest evidence manifests beyond the retention limit.
|
|
347
|
+
*
|
|
348
|
+
* Ordered by the timestamp embedded in the manifest id rather than by mtime:
|
|
349
|
+
* a `git checkout` or an rsync rewrites mtimes and would otherwise make the
|
|
350
|
+
* pruner delete the newest evidence it has.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} root
|
|
353
|
+
* @param {number} [retention=EVIDENCE_RETENTION]
|
|
354
|
+
* @returns {{ pruned: number, kept: number }}
|
|
355
|
+
*/
|
|
356
|
+
export function pruneEvidenceManifests(root, retention = EVIDENCE_RETENTION) {
|
|
357
|
+
const evidenceDir = join(root, ".agent", "evidence");
|
|
358
|
+
if (!existsSync(evidenceDir)) return { pruned: 0, kept: 0 };
|
|
359
|
+
|
|
360
|
+
let entries;
|
|
361
|
+
try {
|
|
362
|
+
entries = readdirSync(evidenceDir);
|
|
363
|
+
} catch (_) {
|
|
364
|
+
return { pruned: 0, kept: 0 };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const manifests = entries
|
|
368
|
+
.filter((f) => f.startsWith("EVD-") && f.endsWith(".json"))
|
|
369
|
+
.map((f) => {
|
|
370
|
+
const match = /^EVD-(\d+)-/.exec(f);
|
|
371
|
+
return { file: f, ts: match ? Number(match[1]) : 0 };
|
|
372
|
+
})
|
|
373
|
+
.sort((a, b) => b.ts - a.ts);
|
|
374
|
+
|
|
375
|
+
if (manifests.length <= retention) return { pruned: 0, kept: manifests.length };
|
|
376
|
+
|
|
377
|
+
let pruned = 0;
|
|
378
|
+
for (const { file } of manifests.slice(retention)) {
|
|
379
|
+
try {
|
|
380
|
+
unlinkSync(join(evidenceDir, file));
|
|
381
|
+
pruned++;
|
|
382
|
+
} catch (_) {}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { pruned, kept: retention };
|
|
386
|
+
}
|
|
387
|
+
|
|
313
388
|
/**
|
|
314
389
|
* Loads and validates an evidence manifest from disk.
|
|
315
390
|
* @param {string} root
|
|
@@ -402,6 +477,32 @@ export function verifyEvidenceManifest(root = process.cwd(), manifestOrPath = "m
|
|
|
402
477
|
return { ok: true, manifestId: manifest.manifestId, evidenceHash: manifest.evidenceHash };
|
|
403
478
|
}
|
|
404
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Exports a structured, machine-readable JSON report optimized for agent consumption.
|
|
482
|
+
* @param {object} manifest
|
|
483
|
+
* @param {string} [customPath]
|
|
484
|
+
* @returns {object}
|
|
485
|
+
*/
|
|
486
|
+
export function exportJsonReport(manifest, customPath = null) {
|
|
487
|
+
const report = {
|
|
488
|
+
schema: manifest.schema || "agentctl/evidence-manifest-v1",
|
|
489
|
+
manifestId: manifest.manifestId,
|
|
490
|
+
generatedAt: manifest.generatedAt,
|
|
491
|
+
status: manifest.status || (manifest.testIntegrity?.tamperDetected ? "failed" : "passed"),
|
|
492
|
+
failedStage: manifest.failedStage || null,
|
|
493
|
+
metrics: manifest.metrics || {},
|
|
494
|
+
diagnostics: manifest.diagnostics || [],
|
|
495
|
+
executionRecords: manifest.executionRecords || [],
|
|
496
|
+
evidenceHash: manifest.evidenceHash,
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
if (customPath) {
|
|
500
|
+
writeFileAtomically(customPath, JSON.stringify(report, null, 2));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return report;
|
|
504
|
+
}
|
|
505
|
+
|
|
405
506
|
/**
|
|
406
507
|
* Renders PR-ready Markdown evidence section.
|
|
407
508
|
* @param {object} manifest
|
|
@@ -422,12 +523,20 @@ export function generateEvidenceMarkdown(manifest) {
|
|
|
422
523
|
|
|
423
524
|
if (Array.isArray(manifest.executionRecords) && manifest.executionRecords.length > 0) {
|
|
424
525
|
lines.push("", "#### Executed Verification Stages");
|
|
425
|
-
lines.push("| Stage | Command | Policy | Exit Code | Duration |");
|
|
526
|
+
lines.push("| Stage | Command / Assertion | Policy | Exit Code | Duration |");
|
|
426
527
|
lines.push("| :--- | :--- | :---: | :---: | :---: |");
|
|
427
528
|
for (const rec of manifest.executionRecords) {
|
|
428
529
|
const statusIcon = rec.exitCode === 0 ? "✅" : "❌";
|
|
429
530
|
const policyStr = rec.networkAccess === "forbidden" ? "🔒 Offline" : rec.networkAccess === "read-only" ? "📖 Read-Only" : "🌐 Online";
|
|
430
|
-
|
|
531
|
+
const cmdOrAssert = rec.assert ? `\`assert:${rec.assert}\`` : `\`${rec.cmd}\``;
|
|
532
|
+
lines.push(`| \`${rec.id || rec.kind}\` | ${cmdOrAssert} | ${policyStr} | ${statusIcon} \`${rec.exitCode}\` | \`${rec.durationMs || 0}ms\` |`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (Array.isArray(manifest.diagnostics) && manifest.diagnostics.length > 0) {
|
|
537
|
+
lines.push("", "#### ⚠️ Diagnostic Findings");
|
|
538
|
+
for (const diag of manifest.diagnostics) {
|
|
539
|
+
lines.push(`- ${diag}`);
|
|
431
540
|
}
|
|
432
541
|
}
|
|
433
542
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { resolveRoot } from "./config.mjs";
|
|
2
|
+
import { resolveRoot, loadConfig } from "./config.mjs";
|
|
3
3
|
import { resolveBase } from "./git.mjs";
|
|
4
4
|
import { classifyRiskTier } from "./risk.mjs";
|
|
5
5
|
import { queryRemediations } from "./remediation.mjs";
|
|
@@ -62,12 +62,28 @@ export function createExecutionEnvelope(task = {}, opts = {}) {
|
|
|
62
62
|
const taskId = task.id || opts.taskId || `task-${Date.now()}`;
|
|
63
63
|
const envelopeId = `env-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// The repository's own resolved scope and verification commands, not a set of
|
|
66
|
+
// Node literals. `loadConfig` merges BUILTIN_DENY/BUILTIN_PROTECT (which are
|
|
67
|
+
// already polyglot — Cargo.toml, go.mod, pyproject.toml, composer.json) with
|
|
68
|
+
// whatever `.agent/config.yml` adds, and `resolveVerify` runs the stack
|
|
69
|
+
// detector. Hardcoding `npm test` here meant the envelope handed a Rust or
|
|
70
|
+
// Python repo a command that could not run, in the very structure whose job
|
|
71
|
+
// is to be the authoritative record of what the agent was allowed to do.
|
|
72
|
+
let config = opts.config;
|
|
73
|
+
if (!config) {
|
|
74
|
+
try {
|
|
75
|
+
config = loadConfig(root);
|
|
76
|
+
} catch (_) {
|
|
77
|
+
config = {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const denyPaths = opts.forbiddenPaths || task.forbiddenPaths || config.scope?.deny || [];
|
|
82
|
+
const allowPaths = opts.allowPaths || task.allowPaths || config.scope?.allow || [];
|
|
83
|
+
const protectPaths = opts.protectPaths || task.protectPaths || config.scope?.protect || [];
|
|
68
84
|
|
|
69
85
|
const files = task.files || opts.files || [];
|
|
70
|
-
const riskMeta = classifyRiskTier(files, { diffLines: opts.diffLines || 0 });
|
|
86
|
+
const riskMeta = classifyRiskTier(files, { diffLines: opts.diffLines || 0, config });
|
|
71
87
|
|
|
72
88
|
const taskFingerprint = task.fingerprint || opts.fingerprint || null;
|
|
73
89
|
const remediations = queryRemediations(root, { targetFiles: files, fingerprint: taskFingerprint, limit: 3 });
|
|
@@ -84,8 +100,8 @@ export function createExecutionEnvelope(task = {}, opts = {}) {
|
|
|
84
100
|
protect: protectPaths,
|
|
85
101
|
},
|
|
86
102
|
verify: {
|
|
87
|
-
testCmd: opts.testCmd || task.testCmd ||
|
|
88
|
-
buildCmd: opts.buildCmd || task.buildCmd ||
|
|
103
|
+
testCmd: opts.testCmd || task.testCmd || config.verify?.test || "",
|
|
104
|
+
buildCmd: opts.buildCmd || task.buildCmd || config.verify?.build || "",
|
|
89
105
|
},
|
|
90
106
|
riskTier: riskMeta.tier,
|
|
91
107
|
budgetReservationId: opts.reservationId || null,
|
package/src/flaky-ledger.mjs
CHANGED
|
@@ -245,7 +245,10 @@ export function clearFlakyLedger(root = resolveRoot(), testCmd = null) {
|
|
|
245
245
|
*/
|
|
246
246
|
export function synthesizeFlakyHealingTask(quarantinedItem, options = {}) {
|
|
247
247
|
const item = typeof quarantinedItem === "string" ? { testCmd: quarantinedItem, oscillation: 0.5, fails: 3, passes: 3, n: 6 } : quarantinedItem;
|
|
248
|
-
|
|
248
|
+
// The quarantined entry carries the command that actually oscillated; the
|
|
249
|
+
// config's resolved test command is the fallback. `npm test` was neither, and
|
|
250
|
+
// in a non-Node repo it produced a healing task that could not run.
|
|
251
|
+
const testCmd = item.testCmd || options.testCmd || options.config?.verify?.test || "";
|
|
249
252
|
const oscillationPct = Math.round((item.oscillation || 0.4) * 100);
|
|
250
253
|
const role = options.role || "janitor";
|
|
251
254
|
const slug = String(testCmd).replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").slice(0, 20).toLowerCase();
|