jules-orchestrator-kit 0.71.0 → 0.72.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/engine.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
1
+ import { loadConfig, resolveTrustedPolicy } from "./config.mjs";
2
2
  import { isTestPath } from "./test-paths.mjs";
3
- import { isPlaceholderTestScript } from "./stack-detector.mjs";
3
+ import { isPlaceholderTestScript, isSrcLayout } from "./stack-detector.mjs";
4
4
  import { checkCollectionFloor } from "./ops/test-collection.mjs";
5
5
  import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets } from "./security.mjs";
6
- import { changedFiles, diffBytes, diffText, binaryDiffEntries, symlinkChanges, showFromOrigin, runCmd } from "./git.mjs";
6
+ import { changedFiles, diffBytes, diffText, binaryDiffEntries, symlinkChanges, showFromOrigin, runCmd, materializeSnapshot } from "./git.mjs";
7
7
  import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
8
8
  import { resolveRoutedProvider } from "./router.mjs";
9
9
  import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked, checkDailyBudget } from "./state.mjs";
@@ -152,13 +152,30 @@ export function fingerprintFailureState(failure = {}, root = process.cwd()) {
152
152
  */
153
153
  export async function gate(opts = {}) {
154
154
  const root = opts.root || process.cwd();
155
- const config = opts.config || loadConfig(root);
156
- const base = opts.base || config.baseBranch || "main";
157
155
  const mode = opts.mode || (opts.workingTree ? "working-tree" : (process.env.JULES_GATE_MODE || "working-tree"));
156
+ const config = opts.config || (mode === "working-tree" ? loadConfig(root) : null);
158
157
  const progressBus = opts.progressBus;
159
158
  const progressToken = opts.progressToken;
160
159
  const phases = [];
161
160
 
161
+ const policy = resolveTrustedPolicy(root, opts.base, mode, { ...opts, config });
162
+ if (!policy.ok) {
163
+ phases.push({
164
+ phase: "scope",
165
+ ok: false,
166
+ error: policy.error,
167
+ violations: [{ file: ".agent/config.yml", rule: "BUILTIN_PROTECT", reason: policy.error }],
168
+ });
169
+ appendTelemetry(root, "gate_finished", { ok: false, code: policy.code || 3, error: policy.error });
170
+ return { ok: false, code: policy.code || 3, phases, error: policy.error, base: policy.base };
171
+ }
172
+
173
+ const base = policy.base;
174
+ const trustedScope = policy.scope;
175
+ let trustedVerify = policy.verify;
176
+ const trustedDiffKb = policy.limits?.diffKb || 75;
177
+ const trustedEvidence = policy.evidence || { strictTestLock: true };
178
+
162
179
  appendTelemetry(root, "gate_started", { base, mode });
163
180
 
164
181
  let files = [];
@@ -172,73 +189,15 @@ export async function gate(opts = {}) {
172
189
  } catch (err) {
173
190
  phases.push({ phase: "git_resolution", ok: false, error: err.message });
174
191
  appendTelemetry(root, "gate_finished", { ok: false, code: 1, error: err.message });
175
- return { ok: false, code: 1, phases, error: err.message };
176
- }
177
-
178
- // Phase 1: Scope Guard (Fetch trusted config strictly from origin/base using single normalizeScope)
179
- let trustedScope = config.scope;
180
- let trustedVerify = config.verify;
181
- let trustedDiffKb = 75;
182
-
183
- const trustedConfigRaw = showFromOrigin(root, base, ".agent/config.yml") || showFromOrigin(root, base, ".agent/jules.yml");
184
- if (trustedConfigRaw) {
185
- try {
186
- const parsed = parseYaml(trustedConfigRaw);
187
- // CRITICAL B1 FIX: normalizeScope ensures BUILTIN_DENY is ALWAYS merged with user deny rules
188
- trustedScope = normalizeScope(parsed);
189
- if (parsed.limits?.diff_kb || parsed.limits?.diffKb) {
190
- trustedDiffKb = Number(parsed.limits.diff_kb || parsed.limits.diffKb) || 75;
191
- }
192
- if (parsed.verify || parsed.test_cmd || parsed.build_cmd) {
193
- trustedVerify = {
194
- setup: parsed.verify?.setup || config.verify.setup,
195
- lint: parsed.verify?.lint || parsed.lint_cmd || config.verify.lint,
196
- test: parsed.verify?.test || parsed.test_cmd || config.verify.test,
197
- unit: parsed.verify?.unit || config.verify.unit || parsed.verify?.test || parsed.test_cmd || config.verify.test,
198
- fuzz: parsed.verify?.fuzz || parsed.fuzz_cmd || config.verify.fuzz,
199
- invariant: parsed.verify?.invariant || parsed.invariant_cmd || config.verify.invariant,
200
- e2e: parsed.verify?.e2e || parsed.e2e_cmd || config.verify.e2e,
201
- teardown: parsed.verify?.teardown || config.verify.teardown,
202
- build: parsed.verify?.build || parsed.build_cmd || config.verify.build,
203
- stages: parsed.verify?.stages || config.verify.stages,
204
- policy: parsed.verify?.policy || config.verify.policy,
205
- // Read from the base commit like every other trusted field: an
206
- // uncommitted `required: false` must not be able to switch the gate off.
207
- required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : config.verify.required !== false,
208
- // The floor the collection check applies. Omitting it here meant
209
- // `verify.minTests` was silently dropped and always defaulted to 1
210
- // — while the failure message told the operator to set exactly
211
- // that. A remediation hint that does nothing is worse than none.
212
- minTests:
213
- parsed.verify?.minTests !== undefined
214
- ? parsed.verify.minTests
215
- : parsed.verify?.min_tests !== undefined
216
- ? parsed.verify.min_tests
217
- : config.verify.minTests,
218
- tamperGuard: parsed.verify?.tamperGuard || parsed.verify?.tamper_guard || config.verify.tamperGuard,
219
- scope: parsed.verify?.scope || config.verify.scope || "global",
220
- timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
221
- };
222
- }
223
- } catch (_) {}
224
- } else {
225
- // CRITICAL H-c FIX: Fall back strictly to normalizeScope({}) (built-ins only), never HEAD config
226
- trustedScope = normalizeScope({});
227
- if (opts.config?.limits?.diffKb || opts.config?.limits?.diff_kb) {
228
- trustedDiffKb = Number(opts.config.limits.diffKb || opts.config.limits.diff_kb) || 75;
229
- }
192
+ return { ok: false, code: 1, phases, error: err.message, base };
230
193
  }
231
194
 
232
- // A symlink is judged by its own name, so `notes.md -> .agent/config.yml`
233
- // walked straight past a deny list that names the target. Judge both: the
234
- // link because it is what the diff adds, and the path it resolves to because
235
- // that is what it grants reach to.
195
+ // Phase 1: Scope Guard
236
196
  let symlinks = [];
237
197
  try {
238
198
  symlinks = symlinkChanges(root, base, mode);
239
199
  } catch (_) {
240
- // Scope must still be enforced on the ordinary file list if git cannot
241
- // describe the links.
200
+ // Scope must still be enforced on the ordinary file list if git cannot describe the links.
242
201
  }
243
202
  const scopeCandidates = [...files];
244
203
  const symlinkTargetOf = new Map();
@@ -249,7 +208,12 @@ export async function gate(opts = {}) {
249
208
  }
250
209
 
251
210
  const scopeResult = checkScope(scopeCandidates, trustedScope, {
252
- allowProtected: opts.allowProtected || process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true",
211
+ allowProtected:
212
+ opts.allowProtected ||
213
+ process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true" ||
214
+ process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "1" ||
215
+ process.env.AGENT_ALLOW_COMMAND_FILE_CHANGES === "true" ||
216
+ process.env.AGENT_ALLOW_COMMAND_FILE_CHANGES === "1",
253
217
  });
254
218
  // Report the violation against the link the change actually introduced, not
255
219
  // against a path the diff never names — the operator has to be able to find it.
@@ -263,23 +227,8 @@ export async function gate(opts = {}) {
263
227
 
264
228
  // Bootstrap: the files that bring a repository under the gate are not agent
265
229
  // edits to the gate.
266
- //
267
- // `init` writes `.agent/**` and then tells the user to commit it. Doing
268
- // exactly that produced Exit 3 on the very first run, because the base
269
- // branch does not have the commit yet and every scaffolded path matches
270
- // BUILTIN_PROTECT or BUILTIN_DENY. The advice printed alongside it was
271
- // `--allow-protected` — so a newcomer's first lesson was how to switch the
272
- // scope guard off. A gate that refuses its own installation is not strict,
273
- // it is broken.
274
- //
275
- // Narrow on purpose, and only where it cannot weaken anything: the base
276
- // commit must have no gate config at all — in which case `trustedScope` is
277
- // already built-ins only and nothing in the added files is trusted — and
278
- // every violating path must be scaffold that the base does not have. A
279
- // repository already under the gate keeps the full rule, so an agent still
280
- // cannot touch the policy it is governed by.
281
230
  let acceptedScaffold = [];
282
- if (!scopeResult.ok && !trustedConfigRaw) {
231
+ if (!scopeResult.ok && policy.isBootstrap) {
283
232
  const violations = scopeResult.violations || [];
284
233
  const isScaffold = (f) => typeof f === "string" && f.replace(/\\/g, "/").startsWith(".agent/");
285
234
  if (
@@ -307,7 +256,7 @@ export async function gate(opts = {}) {
307
256
 
308
257
  if (!scopeResult.ok) {
309
258
  appendTelemetry(root, "gate_finished", { ok: false, code: 3 });
310
- return { ok: false, code: 3, phases };
259
+ return { ok: false, code: 3, phases, base };
311
260
  }
312
261
 
313
262
  // Phase 2: Diff Payload Governor
@@ -359,12 +308,18 @@ export async function gate(opts = {}) {
359
308
  return { ok: false, code: 6, phases };
360
309
  }
361
310
 
362
- // Pre-verification: compute test directory integrity hash
363
- const preTestHashResult = computeDirectoryHash(root, { testOnly: true });
364
- const preTestHash = preTestHashResult.treeHash;
365
- const executionRecords = [];
366
-
367
311
  // Phase 4: Automated Test & Build Verification (Uses trusted verify commands only)
312
+ let snapshot = { cwd: root, cleanup: () => {}, mode };
313
+ try {
314
+ if (mode === "staged" || mode === "committed") {
315
+ snapshot = materializeSnapshot(root, mode, base);
316
+ }
317
+ } catch (snapErr) {
318
+ phases.push({ phase: "verify", ok: false, error: snapErr.message });
319
+ appendTelemetry(root, "gate_finished", { ok: false, code: snapErr.code || 1, error: snapErr.message });
320
+ return { ok: false, code: snapErr.code || 1, phases, error: snapErr.message, base };
321
+ }
322
+
368
323
  let testResult = { ok: true, status: 0 };
369
324
  let buildResult = { ok: true, status: 0 };
370
325
  let serverResult = { ok: true, status: 0 };
@@ -400,21 +355,10 @@ export async function gate(opts = {}) {
400
355
  const verifyTimeout = trustedVerify.timeoutMs || 300_000;
401
356
 
402
357
  // Monorepo scoping: run the suites the change can actually break.
403
- //
404
- // `resolveWorkspaceBoundary()` shipped, was drawn in the architecture
405
- // diagrams and documented as a headline feature — and was called by nothing.
406
- // Every change in a monorepo ran the root suite, so a one-package edit was
407
- // gated on every other package's tests. It resolves the changed files to
408
- // their sub-projects and composes the per-project commands.
409
- //
410
- // It stays opt-in (`verify.scope: affected`) because narrowing what runs is
411
- // only safe when someone asked for it, and it yields to the global command
412
- // whenever a shared file is touched or no sub-project command is found — a
413
- // narrower run that misses the breakage is worse than a slow one.
414
358
  let boundary = null;
415
359
  if (trustedVerify.scope === "affected" && !(Array.isArray(trustedVerify.stages) && trustedVerify.stages.length > 0)) {
416
360
  try {
417
- boundary = resolveWorkspaceBoundary(files, root);
361
+ boundary = resolveWorkspaceBoundary(files, snapshot.cwd);
418
362
  } catch (_) {
419
363
  boundary = null;
420
364
  }
@@ -440,6 +384,13 @@ export async function gate(opts = {}) {
440
384
  stagesToRun.push(...buildDefaultStages(trustedVerify));
441
385
  }
442
386
 
387
+ // Pre-verification: compute test directory integrity hash on snapshot under test
388
+ const preTestHashResult = computeDirectoryHash(snapshot.cwd, { testOnly: true });
389
+ let preTestHash = preTestHashResult.treeHash;
390
+ let postTestHash = preTestHash;
391
+ const executionRecords = [];
392
+ let testTampered = false;
393
+
443
394
  try {
444
395
  for (const stage of stagesToRun) {
445
396
  const isAssertion = Boolean(
@@ -459,18 +410,6 @@ export async function gate(opts = {}) {
459
410
  let assertMetrics = {};
460
411
 
461
412
  if (isAssertion) {
462
- // The loosening flags have to reach here too.
463
- //
464
- // `scanDiff` above is given `allowTestChanges` and honours it, while
465
- // the `assert:test-integrity` stage received only its own stage object
466
- // and re-ran the same guard with none of them. So an override was
467
- // accepted by one phase and ignored by the next: `--allow-test-change
468
- // deregistration` turned the SECRETS phase green and then failed the
469
- // run at the anti-tamper stage, with a flag hint the operator had
470
- // already followed. One rule, two places, and the second kept the old
471
- // answer — for the eighth time in this project's history, which is why
472
- // the regression test asserts the verdict a caller receives rather
473
- // than the behaviour of either site.
474
413
  const assertRes = runAssertion(
475
414
  {
476
415
  ...stage,
@@ -479,7 +418,7 @@ export async function gate(opts = {}) {
479
418
  tamperGuard: trustedVerify.tamperGuard,
480
419
  allowUnreadableTests: opts.allowUnreadableTests === true,
481
420
  },
482
- root
421
+ snapshot.cwd
483
422
  );
484
423
  durationMs = assertRes.metrics?.durationMs ?? (Date.now() - startTime);
485
424
  stdoutRedacted = assertRes.stdout || "";
@@ -491,9 +430,16 @@ export async function gate(opts = {}) {
491
430
  } else {
492
431
  const stageEnv = stage.networkAccess === "forbidden" || trustedVerify.policy?.offline
493
432
  ? { ...testEnv, JULES_SANDBOX_OFFLINE: "1" }
494
- : testEnv;
433
+ : { ...testEnv };
434
+
435
+ // F11: Prepend src/ to PYTHONPATH for Python src-layout repositories
436
+ if (isSrcLayout(snapshot.cwd)) {
437
+ const currentPypath = stageEnv.PYTHONPATH || "";
438
+ const srcDir = join(snapshot.cwd, "src");
439
+ stageEnv.PYTHONPATH = currentPypath ? `${srcDir}:${currentPypath}` : srcDir;
440
+ }
495
441
 
496
- res = runCmd(stage.cmd, { cwd: root, ignoreError: true, env: stageEnv, timeout: stage.timeoutMs || verifyTimeout });
442
+ res = runCmd(stage.cmd, { cwd: snapshot.cwd, ignoreError: true, env: stageEnv, timeout: stage.timeoutMs || verifyTimeout });
497
443
  durationMs = Date.now() - startTime;
498
444
  stdoutRedacted = redactSecrets(res.stdout || "");
499
445
  stderrRedacted = redactSecrets(res.stderr || "");
@@ -514,7 +460,7 @@ export async function gate(opts = {}) {
514
460
 
515
461
  if (stage.kind === "test" || stage.kind === "unit") {
516
462
  testResult = { ok: stageOk, status: res.status, stdout: stdoutRedacted, stderr: stderrRedacted, command: stage.cmd };
517
- const fingerprint = !stageOk ? fingerprintFailureState(testResult, root) : null;
463
+ const fingerprint = !stageOk ? fingerprintFailureState(testResult, snapshot.cwd) : null;
518
464
  if (stage.cmd) recordVerifyRun(root, stage.cmd, stageOk, fingerprint, durationMs);
519
465
  if (!stageOk && stage.cmd) {
520
466
  const runs = readVerifyRuns(root, stage.cmd);
@@ -538,7 +484,7 @@ export async function gate(opts = {}) {
538
484
  });
539
485
  appendTelemetry(root, "gate_phase", { phase: "verify", ok: false, quarantined: true });
540
486
  appendTelemetry(root, "gate_finished", { ok: false, code: 8 });
541
- return { ok: false, code: 8, phases, flakyVerdict: flakyVerdictResult };
487
+ return { ok: false, code: 8, phases, flakyVerdict: flakyVerdictResult, base };
542
488
  }
543
489
  }
544
490
  } else if (stage.kind === "build") {
@@ -560,30 +506,30 @@ export async function gate(opts = {}) {
560
506
  break;
561
507
  }
562
508
  }
509
+
510
+ if (!failingCmd && trustedVerify.server && trustedVerify.server.command) {
511
+ serverResult = await probeDevServer(trustedVerify.server, snapshot.cwd);
512
+ if (!serverResult.ok) {
513
+ failingCmd = serverResult;
514
+ }
515
+ }
516
+
517
+ // Post-verification test integrity check (No Test Weakening Invariant)
518
+ const postTestHashResult = computeDirectoryHash(snapshot.cwd, { testOnly: true });
519
+ postTestHash = postTestHashResult.treeHash;
520
+ if (trustedEvidence.strictTestLock && !opts.allowTestModifications && preTestHashResult.fileCount > 0) {
521
+ const changedTestFile = files.find((f) => isTestPath(f));
522
+ if (changedTestFile && preTestHash !== postTestHash) {
523
+ testTampered = true;
524
+ }
525
+ }
563
526
  } finally {
564
527
  if (trustedVerify.teardown) {
565
528
  try {
566
- runCmd(trustedVerify.teardown, { cwd: root, ignoreError: true, env: testEnv });
529
+ runCmd(trustedVerify.teardown, { cwd: snapshot.cwd, ignoreError: true, env: testEnv });
567
530
  } catch (_) {}
568
531
  }
569
- }
570
-
571
- if (!failingCmd && trustedVerify.server && trustedVerify.server.command) {
572
- serverResult = await probeDevServer(trustedVerify.server, root);
573
- if (!serverResult.ok) {
574
- failingCmd = serverResult;
575
- }
576
- }
577
-
578
- // Post-verification test integrity check (No Test Weakening Invariant)
579
- const postTestHashResult = computeDirectoryHash(root, { testOnly: true });
580
- const postTestHash = postTestHashResult.treeHash;
581
- let testTampered = false;
582
- if (config.evidence?.strictTestLock && !opts.allowTestModifications && preTestHashResult.fileCount > 0) {
583
- const changedTestFile = files.find((f) => isTestPath(f));
584
- if (changedTestFile && preTestHash !== postTestHash) {
585
- testTampered = true;
586
- }
532
+ snapshot.cleanup();
587
533
  }
588
534
 
589
535
  // A gate that ran no verification at all must not report APPROVED.
@@ -717,9 +663,9 @@ export async function gate(opts = {}) {
717
663
  executionRecords,
718
664
  secretScanOk: secretResult.ok,
719
665
  diffKb: Math.round(bytes / 1024),
720
- maxDiffKb: config.limits.diffKb || 75,
666
+ maxDiffKb: trustedDiffKb,
721
667
  protectedScopeOk: scopeResult.ok,
722
- repository: config.provider || "jules",
668
+ repository: policy.provider || config?.provider || "jules",
723
669
  failedStage: failingCmd?.stageId || failingCmd?.phase || null,
724
670
  diagnostics: failingCmd?.diagnostics?.length ? failingCmd.diagnostics : (failingCmd?.stderr ? [failingCmd.stderr] : []),
725
671
  metrics: failingCmd?.metrics || {},
@@ -730,9 +676,18 @@ export async function gate(opts = {}) {
730
676
  evidenceManifest.testIntegrity.tamperDetected = true;
731
677
  }
732
678
  let evidencePath = null;
733
- try {
734
- evidencePath = writeEvidenceManifest(root, evidenceManifest);
735
- } catch (_) {}
679
+ const shouldPersistEvidence =
680
+ !opts.dryRun &&
681
+ !opts["dry-run"] &&
682
+ process.env.JULES_DRY_RUN !== "1" &&
683
+ process.env.AGENT_DRY_RUN !== "1" &&
684
+ trustedEvidence.enabled !== false &&
685
+ config?.evidence?.enabled !== false;
686
+ if (shouldPersistEvidence) {
687
+ try {
688
+ evidencePath = writeEvidenceManifest(root, evidenceManifest);
689
+ } catch (_) {}
690
+ }
736
691
 
737
692
  // Export structured JSON report if requested
738
693
  const jsonReportPath = opts.jsonReport || opts.jsonReportPath || process.env.JULES_JSON_REPORT || null;
package/src/git.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
- import { readFileSync, existsSync, statSync, lstatSync, readlinkSync } from "node:fs";
2
+ import { readFileSync, existsSync, statSync, lstatSync, readlinkSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
3
3
  import { join, delimiter } from "node:path";
4
+ import { tmpdir } from "node:os";
4
5
  import { normalizePath, canonicalizePath } from "./config.mjs";
5
6
 
6
7
 
@@ -676,6 +677,108 @@ export function worktreePrune(root = process.cwd()) {
676
677
  return git(["worktree", "prune"], { cwd: root });
677
678
  }
678
679
 
680
+ /**
681
+ * Materialize the revision under review into an isolated execution directory (F10).
682
+ *
683
+ * In working-tree mode, verification executes against the live tree (`root`).
684
+ * In staged or committed mode, the verification command must execute against the
685
+ * snapshot being judged, rather than the working copy — attesting a revision
686
+ * you did not execute is the failure this tool exists to refuse.
687
+ *
688
+ * @param {string} root - repository root
689
+ * @param {string} mode - "working-tree" | "staged" | "committed"
690
+ * @param {string} [targetRef="HEAD"] - commit reference for committed mode
691
+ * @returns {{ cwd: string, cleanup: () => void, mode: string }}
692
+ */
693
+ export function materializeSnapshot(root = process.cwd(), mode = "working-tree", targetRef = "HEAD") {
694
+ if (mode === "working-tree" || mode === "working") {
695
+ return {
696
+ cwd: root,
697
+ cleanup: () => {},
698
+ mode,
699
+ };
700
+ }
701
+
702
+ const prefix = `jules-${mode}-snapshot-`;
703
+ const tmpDir = mkdtempSync(join(tmpdir(), prefix));
704
+ let worktreeAdded = false;
705
+
706
+ try {
707
+ if (mode === "staged" || mode === "index") {
708
+ execFileSync("git", ["-c", "core.quotePath=false", "checkout-index", "--all", `--prefix=${tmpDir}/`], {
709
+ cwd: root,
710
+ encoding: "utf-8",
711
+ stdio: ["ignore", "pipe", "pipe"],
712
+ });
713
+ } else {
714
+ // committed mode
715
+ try {
716
+ execFileSync("git", ["worktree", "add", "--detach", tmpDir, targetRef || "HEAD"], {
717
+ cwd: root,
718
+ stdio: ["ignore", "pipe", "pipe"],
719
+ });
720
+ worktreeAdded = true;
721
+ } catch (_) {
722
+ // Fallback: git archive piped to tar when worktree cannot be created (e.g. read-only .git)
723
+ try {
724
+ const tar = execFileSync("git", ["archive", targetRef || "HEAD"], {
725
+ cwd: root,
726
+ maxBuffer: 50 * 1024 * 1024,
727
+ });
728
+ execFileSync("tar", ["-x", "-C", tmpDir], { input: tar, stdio: ["pipe", "ignore", "pipe"] });
729
+ } catch (tarErr) {
730
+ throw new Error(`Failed to extract snapshot for ${targetRef || "HEAD"}: ${tarErr.message}`);
731
+ }
732
+ }
733
+ }
734
+
735
+ // Preserve local runtime dependencies in snapshot so offline/sandboxed verification can run:
736
+ const ignoredDeps = ["node_modules", ".venv", "venv", "target", ".pytest_cache"];
737
+ for (const dep of ignoredDeps) {
738
+ const srcDep = join(root, dep);
739
+ const dstDep = join(tmpDir, dep);
740
+ if (existsSync(srcDep) && !existsSync(dstDep)) {
741
+ try {
742
+ symlinkSync(srcDep, dstDep, "junction");
743
+ } catch (_) {}
744
+ }
745
+ }
746
+
747
+ const cleanup = () => {
748
+ if (worktreeAdded) {
749
+ try {
750
+ execFileSync("git", ["worktree", "remove", "--force", tmpDir], {
751
+ cwd: root,
752
+ stdio: ["ignore", "ignore", "ignore"],
753
+ });
754
+ } catch (_) {}
755
+ }
756
+ try {
757
+ rmSync(tmpDir, { recursive: true, force: true });
758
+ } catch (_) {}
759
+ };
760
+
761
+ return {
762
+ cwd: tmpDir,
763
+ cleanup,
764
+ mode,
765
+ };
766
+ } catch (err) {
767
+ if (worktreeAdded) {
768
+ try {
769
+ execFileSync("git", ["worktree", "remove", "--force", tmpDir], {
770
+ cwd: root,
771
+ stdio: ["ignore", "ignore", "ignore"],
772
+ });
773
+ } catch (_) {}
774
+ }
775
+ try {
776
+ rmSync(tmpDir, { recursive: true, force: true });
777
+ } catch (_) {}
778
+ throw new GateError(`Failed to materialize ${mode} snapshot for verification: ${err.message}`, { code: 1 });
779
+ }
780
+ }
781
+
679
782
  /**
680
783
  * Extract 'owner/repo' from a git remote URL if it points to GitHub.
681
784
  * Handles SSH (git@github.com:owner/repo.git), HTTPS (https://github.com/owner/repo.git),