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/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(trimmed.slice(2).trim()));
218
+ top.parent[top.key].push(coerce(itemBody));
182
219
  } else if (Array.isArray(top.node)) {
183
- top.node.push(coerce(trimmed.slice(2).trim()));
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
- /** The tier used when a config names one that does not exist. */
361
- export const FALLBACK_TIER = "ultra";
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 || "ultra").toLowerCase();
410
- const activeTier = TIER_PRESETS[rawTier] ? rawTier : "ultra";
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()),
@@ -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
- if (!stage.cmd) continue;
300
- const startTime = Date.now();
301
- const stageEnv = stage.networkAccess === "forbidden" || trustedVerify.policy?.offline
302
- ? { ...testEnv, JULES_SANDBOX_OFFLINE: "1" }
303
- : testEnv;
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 res = runCmd(stage.cmd, { cwd: root, ignoreError: true, env: stageEnv, timeout: stage.timeoutMs || verifyTimeout });
306
- const durationMs = Date.now() - startTime;
307
- const stdoutRedacted = redactSecrets(res.stdout || "");
308
- const stderrRedacted = redactSecrets(res.stderr || "");
309
- const stageOk = res.status === 0;
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
- const verifyOk = !failingCmd && !testTampered;
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 (initialStatus === "COMPLETED" || initialStatus === "FAILED" || opts.dryRun || session.id === "dry-run-session-id") {
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
- lines.push(`| \`${rec.id || rec.kind}\` | \`${rec.cmd}\` | ${policyStr} | ${statusIcon} \`${rec.exitCode}\` | \`${rec.durationMs || 0}ms\` |`);
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
- const denyPaths = opts.forbiddenPaths || task.forbiddenPaths || [".github/**", "package-lock.json", "pnpm-lock.yaml"];
66
- const allowPaths = opts.allowPaths || task.allowPaths || [];
67
- const protectPaths = opts.protectPaths || task.protectPaths || ["package.json", "tsconfig.json", "AGENTS.md"];
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 || "npm test",
88
- buildCmd: opts.buildCmd || task.buildCmd || "npm run build",
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,
@@ -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
- const testCmd = item.testCmd || "npm test";
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();