jules-orchestrator-kit 0.52.3 → 0.52.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -143,7 +143,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
143
143
  * **Fail-Closed Security & Secret Redaction:** Evaluates explicit Deny rules before Allow rules against canonicalized, case-folded paths. Redacts high-entropy keys and base64-encoded credentials (such as Kubernetes `Secret` manifests).
144
144
  * **Complexity & Cost Router:** Zero-dependency heuristic classifier (`src/router.mjs`) routing mechanical tasks to lightweight models while reserving primary models for complex refactors, with a `node --check` syntax-verification gate that transparently escalates a FAST-tier result to the primary provider if it left broken JS on disk.
145
145
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
146
- * **Verified Test Suite:** Tested with **809 unit tests across 91 suites passing in < 10.0s**.
146
+ * **Verified Test Suite:** Tested with **828 unit tests across 91 suites passing in < 10.0s**.
147
147
 
148
148
  <br/>
149
149
 
package/bin/agentctl.mjs CHANGED
@@ -427,7 +427,10 @@ async function main() {
427
427
  } else if (res.flakyVerdict?.verdict === "QUARANTINED") {
428
428
  console.log(`💡 Remediation Hint (Exit 8 Flaky Test Quarantined):`);
429
429
  console.log(` • This command has alternated between pass and fail across recent runs.`);
430
- console.log(` • Fix the test's non-determinism — re-running will not clear the verdict.\n`);
430
+ } else if (res.code === 188) {
431
+ console.log(`💡 Remediation Hint (Exit 188 Offline Network Violation / Infrastructure):`);
432
+ console.log(` • An unmocked network request was blocked by the preload network guard during verification.`);
433
+ console.log(` • Ensure dependencies are installed locally (run: npm install) and all network calls in tests are mocked.\n`);
431
434
  } else if (failedPhase === "verify" || failedPhase === "evidence") {
432
435
  console.log(`💡 Remediation Hint (Exit ${res.code} Verification Failed):`);
433
436
  console.log(` • The stage above exited non-zero. Reproduce it locally, then re-run the gate.`);
package/index.mjs CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  checkScope,
15
15
  scanDiff,
16
16
  hasEncodedSecret,
17
+ hasHighEntropyToken,
17
18
  checkEdgeRuntimeImports,
18
19
  checkCrossPackageImports,
19
20
  checkTestTampering,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.52.3",
3
+ "version": "0.52.5",
4
4
  "description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for Google Jules (jules) autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.mjs CHANGED
@@ -35,13 +35,13 @@ export const BUILTIN_DENY = [
35
35
  "**/*.pem",
36
36
  "**/*.key",
37
37
  "**/id_rsa*",
38
- ".agent/config.yml",
39
- ".agent/jules.yml",
40
38
  ".agent/jules-queue/**",
41
39
  ".github/**",
42
40
  ];
43
41
 
44
42
  export const BUILTIN_PROTECT = [
43
+ ".agent/config.yml",
44
+ ".agent/jules.yml",
45
45
  ".agent/rules/**",
46
46
  ".agent/SYSTEM_LEARNINGS.md",
47
47
  ".agent/knowledge/**",
package/src/engine.mjs CHANGED
@@ -173,6 +173,7 @@ export async function gate(opts = {}) {
173
173
  // Phase 1: Scope Guard (Fetch trusted config strictly from origin/base using single normalizeScope)
174
174
  let trustedScope = config.scope;
175
175
  let trustedVerify = config.verify;
176
+ let trustedDiffKb = 75;
176
177
 
177
178
  const trustedConfigRaw = showFromOrigin(root, base, ".agent/config.yml") || showFromOrigin(root, base, ".agent/jules.yml");
178
179
  if (trustedConfigRaw) {
@@ -180,6 +181,9 @@ export async function gate(opts = {}) {
180
181
  const parsed = parseYaml(trustedConfigRaw);
181
182
  // CRITICAL B1 FIX: normalizeScope ensures BUILTIN_DENY is ALWAYS merged with user deny rules
182
183
  trustedScope = normalizeScope(parsed);
184
+ if (parsed.limits?.diff_kb || parsed.limits?.diffKb) {
185
+ trustedDiffKb = Number(parsed.limits.diff_kb || parsed.limits.diffKb) || 75;
186
+ }
183
187
  if (parsed.verify || parsed.test_cmd || parsed.build_cmd) {
184
188
  trustedVerify = {
185
189
  setup: parsed.verify?.setup || config.verify.setup,
@@ -200,6 +204,9 @@ export async function gate(opts = {}) {
200
204
  } else {
201
205
  // CRITICAL H-c FIX: Fall back strictly to normalizeScope({}) (built-ins only), never HEAD config
202
206
  trustedScope = normalizeScope({});
207
+ if (opts.config?.limits?.diffKb || opts.config?.limits?.diff_kb) {
208
+ trustedDiffKb = Number(opts.config.limits.diffKb || opts.config.limits.diff_kb) || 75;
209
+ }
203
210
  }
204
211
 
205
212
  const scopeResult = checkScope(files, trustedScope, {
@@ -218,7 +225,8 @@ export async function gate(opts = {}) {
218
225
  }
219
226
 
220
227
  // Phase 2: Diff Payload Governor
221
- const limitBytes = (config.limits.diffKb || 75) * 1024;
228
+ // Bound to trusted base commit config strictly; uncommitted disk config cannot raise limit in committed mode.
229
+ const limitBytes = trustedDiffKb * 1024;
222
230
  const payloadOk = bytes <= limitBytes;
223
231
  phases.push({ phase: "payload", ok: payloadOk, bytes, limitBytes });
224
232
  appendTelemetry(root, "gate_phase", { phase: "payload", ok: payloadOk });
@@ -433,6 +441,14 @@ export async function gate(opts = {}) {
433
441
  // the one gate failure the operator is expected to fix themselves was the
434
442
  // only one that told them nothing about how. Both streams are already
435
443
  // redacted at the point they were captured.
444
+ const isNetViolation = failingCmd?.status === 188;
445
+ const netViolationDiagnostics = isNetViolation
446
+ ? [
447
+ "Offline Network Guard: Outbound network request was blocked (Exit 188).",
448
+ "Ensure dependencies are installed locally (run npm install) and tests do not perform unmocked network access.",
449
+ ]
450
+ : [];
451
+
436
452
  const verifyFailure = failingCmd
437
453
  ? {
438
454
  stageId: failingCmd.stageId || failingCmd.phase || "verify",
@@ -440,7 +456,7 @@ export async function gate(opts = {}) {
440
456
  exitCode: failingCmd.status ?? null,
441
457
  stdout: failingCmd.stdout || "",
442
458
  stderr: failingCmd.stderr || "",
443
- diagnostics: failingCmd.diagnostics || [],
459
+ diagnostics: [...netViolationDiagnostics, ...(failingCmd.diagnostics || [])],
444
460
  }
445
461
  : testTampered
446
462
  ? {
@@ -510,6 +526,10 @@ export async function gate(opts = {}) {
510
526
  }
511
527
 
512
528
  if (!verifyOk && opts.fix && failingCmd) {
529
+ if (isNetViolation) {
530
+ appendTelemetry(root, "gate_finished", { ok: false, code: 188, networkViolation: true });
531
+ return { ok: false, code: 188, phases, evidence: evidenceManifest };
532
+ }
513
533
  const repairs = await repair(failingCmd, { config, root, signal: opts.signal, progressBus, progressToken });
514
534
  const finalOk = repairs.ok;
515
535
  const finalCode = repairs.ok ? 0 : 4;
@@ -520,7 +540,7 @@ export async function gate(opts = {}) {
520
540
  return { ok: false, code: 4, phases, repairs, evidence: evidenceManifest };
521
541
  }
522
542
 
523
- const code = verifyOk ? 0 : testTampered ? 3 : 4;
543
+ const code = verifyOk ? 0 : isNetViolation ? 188 : testTampered ? 3 : 4;
524
544
  appendTelemetry(root, "gate_finished", { ok: verifyOk, code });
525
545
  return { ok: verifyOk, code, phases, evidence: evidenceManifest };
526
546
  }
package/src/git.mjs CHANGED
@@ -306,7 +306,10 @@ export function ensureBaseFetched(root = process.cwd(), baseRef = "main") {
306
306
  }
307
307
 
308
308
  export function resolveBase(root = process.cwd(), baseRef = "main") {
309
- const candidates = [`origin/${baseRef}`, `refs/remotes/origin/${baseRef}`, baseRef];
309
+ const isHeadRef = baseRef === "HEAD" || baseRef.startsWith("HEAD~") || baseRef.startsWith("HEAD^") || baseRef.startsWith("HEAD@");
310
+ const candidates = isHeadRef
311
+ ? [baseRef]
312
+ : [`origin/${baseRef}`, `refs/remotes/origin/${baseRef}`, baseRef];
310
313
  for (const ref of candidates) {
311
314
  try {
312
315
  const res = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
@@ -321,20 +324,22 @@ export function resolveBase(root = process.cwd(), baseRef = "main") {
321
324
  } catch (_) {}
322
325
  }
323
326
 
324
- ensureBaseFetched(root, baseRef);
327
+ if (!isHeadRef) {
328
+ ensureBaseFetched(root, baseRef);
325
329
 
326
- for (const ref of candidates) {
327
- try {
328
- const res = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
329
- cwd: root,
330
- encoding: "utf-8",
331
- shell: false,
332
- stdio: ["ignore", "pipe", "ignore"],
333
- });
334
- if (res && res.trim()) {
335
- return res.trim();
336
- }
337
- } catch (_) {}
330
+ for (const ref of candidates) {
331
+ try {
332
+ const res = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
333
+ cwd: root,
334
+ encoding: "utf-8",
335
+ shell: false,
336
+ stdio: ["ignore", "pipe", "ignore"],
337
+ });
338
+ if (res && res.trim()) {
339
+ return res.trim();
340
+ }
341
+ } catch (_) {}
342
+ }
338
343
  }
339
344
 
340
345
  throw new GateError(
package/src/security.mjs CHANGED
@@ -588,6 +588,11 @@ function secretScanVariants(addedLines) {
588
588
  // Collapse method concatenations like .concat("...") or .join("")
589
589
  dejoined = dejoined.replace(/\.concat\(\s*["'`]/g, "").replace(/\.join\(\s*["'`]{2}\s*\)/g, "");
590
590
 
591
+ // Collapse whitespace/newlines between adjacent base64 characters (including line-wrapped PEM/base64, template literals, and quoted string chunks)
592
+ const base64Dejoined = stripped
593
+ .replace(/([A-Za-z0-9+/=_-])\s*[\r\n]+\s*(?=[A-Za-z0-9+/=_-])/g, "$1")
594
+ .replace(/([A-Za-z0-9+/=_-])["'`]\s*(?:\+\s*)?[\r\n]+\s*["'`]?([A-Za-z0-9+/=_-])/g, "$1$2");
595
+
591
596
  const hexDecoded = tryHexDecodeTokens(dejoined);
592
597
  const pctDecoded = tryPercentDecode(dejoined);
593
598
  // Confusable / NFKD normalisation runs over both the raw text and the
@@ -597,8 +602,9 @@ function secretScanVariants(addedLines) {
597
602
  const confusableDejoined = normalizeSecretText(dejoined);
598
603
 
599
604
  return {
600
- all: [...new Set([addedLines, stripped, dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined])],
605
+ all: [...new Set([addedLines, stripped, dejoined, base64Dejoined, hexDecoded, pctDecoded, confusable, confusableDejoined])],
601
606
  normalized: dejoined,
607
+ base64Normalized: base64Dejoined,
602
608
  };
603
609
  }
604
610
 
@@ -725,9 +731,22 @@ function decodeBase64Blobs(text, onDecoded) {
725
731
  * @returns {boolean}
726
732
  */
727
733
  export function hasEncodedSecret(text) {
734
+ if (!text) return false;
728
735
  const result = decodeBase64Blobs(text);
729
736
  if (result.capped) return true; // Fail closed if cap exceeded
730
- return result.decoded.some((plain) => hasHighConfidenceSecret(plain));
737
+ if (result.decoded.some((plain) => hasHighConfidenceSecret(plain))) return true;
738
+
739
+ if (text.includes("\n") || text.includes("\r")) {
740
+ const collapsed = text
741
+ .replace(/([A-Za-z0-9+/=_-])\s*[\r\n]+\s*(?=[A-Za-z0-9+/=_-])/g, "$1")
742
+ .replace(/([A-Za-z0-9+/=_-])["'`]\s*(?:\+\s*)?[\r\n]+\s*["'`]?([A-Za-z0-9+/=_-])/g, "$1$2");
743
+ if (collapsed !== text) {
744
+ const collapsedResult = decodeBase64Blobs(collapsed);
745
+ if (collapsedResult.capped) return true;
746
+ if (collapsedResult.decoded.some((plain) => hasHighConfidenceSecret(plain))) return true;
747
+ }
748
+ }
749
+ return false;
731
750
  }
732
751
 
733
752
  /**
@@ -776,22 +795,93 @@ function splitDiffByFile(diffText) {
776
795
  return [...byFile.values()].filter((s) => s.lines.length > 0);
777
796
  }
778
797
 
798
+ const CANDIDATE_TOKEN_REGEX = /[A-Za-z0-9_-]{24,}/g;
799
+
800
+ /**
801
+ * Checks for high-entropy continuous tokens (>= 24 chars, entropy > 4.5) on added lines.
802
+ * Filters out URLs, SRI hashes (sha512-, sha256-, sha384-), and lockfiles to eliminate false positives.
803
+ *
804
+ * @param {string} text - Text to scan
805
+ * @param {string|null} [file=null] - File path associated with the text
806
+ * @returns {boolean}
807
+ */
808
+ export function hasHighEntropyToken(text = "", file = null) {
809
+ if (!text || typeof text !== "string") return false;
810
+ if (
811
+ file &&
812
+ (file.endsWith(".lock") ||
813
+ file.endsWith(".lockb") ||
814
+ file.includes("package-lock.json") ||
815
+ file.includes("pnpm-lock.yaml") ||
816
+ file.includes("yarn.lock") ||
817
+ file.includes("Cargo.lock") ||
818
+ file.includes("composer.lock"))
819
+ ) {
820
+ return false;
821
+ }
822
+
823
+ const lines = text.split("\n");
824
+ for (const rawLine of lines) {
825
+ if (
826
+ rawLine.includes("://") ||
827
+ rawLine.includes("data:image/") ||
828
+ rawLine.includes("sha512-") ||
829
+ rawLine.includes("sha256-") ||
830
+ rawLine.includes("sha384-")
831
+ ) {
832
+ continue;
833
+ }
834
+
835
+ CANDIDATE_TOKEN_REGEX.lastIndex = 0;
836
+ let match;
837
+ while ((match = CANDIDATE_TOKEN_REGEX.exec(rawLine)) !== null) {
838
+ const token = match[0];
839
+ if (token.startsWith("sha512-") || token.startsWith("sha256-")) continue;
840
+ if (token.length >= 24) {
841
+ // If the token is a formatted base64 blob, distinguish binary assets and plain prose
842
+ if (token.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(token)) {
843
+ try {
844
+ const plain = Buffer.from(token, "base64").toString("utf-8");
845
+ const pr = printableRatio(plain);
846
+ if (pr < 0.9) {
847
+ // Ordinary binary asset (e.g. icon/font/wasm) - do not trip on binary entropy
848
+ continue;
849
+ }
850
+ // If it decodes to text, check decoded plain text entropy
851
+ if (shannonEntropy(plain) > 4.5) {
852
+ return true;
853
+ }
854
+ continue;
855
+ } catch (_) {}
856
+ }
857
+
858
+ const ent = shannonEntropy(token);
859
+ if (ent > 4.5) {
860
+ return true;
861
+ }
862
+ }
863
+ }
864
+ }
865
+ return false;
866
+ }
867
+
779
868
  /**
780
869
  * Classify a block of added lines. Returns the single most severe finding, or
781
870
  * null when the block is clean.
782
871
  *
783
872
  * @param {string} addedLines
873
+ * @param {string|null} [file=null]
784
874
  * @returns {{ severity: string, type: string, description: string, encoded: boolean }|null}
785
875
  */
786
- function classifyAddedLines(addedLines) {
787
- const { all: variants, normalized } = secretScanVariants(addedLines);
876
+ function classifyAddedLines(addedLines, file = null) {
877
+ const { all: variants, normalized, base64Normalized } = secretScanVariants(addedLines);
788
878
  if (variants.some((v) => hasHighConfidenceSecret(v))) {
789
879
  return { severity: "CRITICAL", type: "HIGH_CONFIDENCE_SECRET", encoded: false, description: "High-confidence secret pattern detected in added diff lines" };
790
880
  }
791
881
  // Only worth decoding when nothing was found in the clear, and only against
792
882
  // the fully-normalised text: decoding is the expensive step, and the
793
883
  // intermediate variants differ from it in ways base64 blobs do not care about.
794
- if (hasEncodedSecret(normalized)) {
884
+ if (hasEncodedSecret(normalized) || (base64Normalized && hasEncodedSecret(base64Normalized))) {
795
885
  // Same type as the cleartext case: every gate that blocks on
796
886
  // HIGH_CONFIDENCE_SECRET should block on this too, and a new type would
797
887
  // have silently passed through the ones not updated. The description
@@ -801,6 +891,9 @@ function classifyAddedLines(addedLines) {
801
891
  if (variants.some((v) => hasLowConfidenceSecret(v))) {
802
892
  return { severity: "HIGH", type: "LOW_CONFIDENCE_SECRET", encoded: false, description: "Low-confidence secret or authorization token detected in added diff lines" };
803
893
  }
894
+ if (hasHighEntropyToken(addedLines, file)) {
895
+ return { severity: "HIGH", type: "HIGH_ENTROPY_TOKEN", encoded: false, description: "High-entropy token detected in added diff lines (potential unstructured secret or API key)" };
896
+ }
804
897
  return null;
805
898
  }
806
899
 
@@ -815,12 +908,13 @@ function classifyAddedLines(addedLines) {
815
908
  *
816
909
  * @param {Array<{ text: string, no: number|null }>} lines
817
910
  * @param {string} type
911
+ * @param {string|null} [file=null]
818
912
  * @returns {number|null}
819
913
  */
820
- function locateFindingLine(lines, type) {
914
+ function locateFindingLine(lines, type, file = null) {
821
915
  for (const line of lines) {
822
916
  if (line.no === null) continue;
823
- const hit = classifyAddedLines(line.text);
917
+ const hit = classifyAddedLines(line.text, file);
824
918
  if (hit && hit.type === type) return line.no;
825
919
  }
826
920
  return null;
@@ -840,8 +934,10 @@ export function checkTestTampering(diffOrText = "", options = {}) {
840
934
 
841
935
  const violations = [];
842
936
  const lines = diffOrText.split("\n");
937
+ let lastOldFile = null;
843
938
  let currentFile = null;
844
- let currentLineNo = null;
939
+ let currentOldLineNo = null;
940
+ let currentNewLineNo = null;
845
941
 
846
942
  const isTestFile = (f) => {
847
943
  if (!f) return false;
@@ -868,18 +964,43 @@ export function checkTestTampering(diffOrText = "", options = {}) {
868
964
 
869
965
  const COMMENTED_ASSERTION = /^\+\s*(?:\/\/|\/\*)\s*(?:expect\(|assert\.|assert\(|t\.expect|t\.assert)/i;
870
966
 
967
+ const VACUOUS_ASSERTIONS = [
968
+ { pattern: /\bassert(?:\.ok)?\s*\(\s*true\s*(?:,[^)]*)?\)/i, desc: "Vacuous truth assertion (assert.ok(true))" },
969
+ { pattern: /\bassert\.(?:strictEqual|deepStrictEqual|equal|deepEqual)\s*\(\s*([^,]+?)\s*,\s*\1\s*(?:,[^)]*)?\)/i, desc: "Vacuous identity assertion (assert.equal(X, X))" },
970
+ { pattern: /\bexpect\s*\(\s*true\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*true\s*\)/i, desc: "Vacuous truth expectation (expect(true).toBe(true))" },
971
+ { pattern: /\bexpect\s*\(\s*([^)]+?)\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*\1\s*\)/i, desc: "Vacuous identity expectation (expect(X).toBe(X))" },
972
+ { pattern: /\bexpect\s*\(\s*true\s*\)\s*\.toBeTruthy\s*\(/i, desc: "Vacuous truth expectation (expect(true).toBeTruthy())" },
973
+ { pattern: /\bexpect\s*\(\s*false\s*\)\s*\.toBeFalsy\s*\(/i, desc: "Vacuous falsity expectation (expect(false).toBeFalsy())" },
974
+ { pattern: /\bassert\.(?:isTrue|isOk)\s*\(\s*true\s*(?:,[^)]*)?\)/i, desc: "Vacuous truth assertion (assert.isTrue(true))" },
975
+ { pattern: /\bassert\.(?:isFalse|isNotOk)\s*\(\s*false\s*(?:,[^)]*)?\)/i, desc: "Vacuous falsity assertion (assert.isFalse(false))" },
976
+ ];
977
+
978
+ const ASSERTION_PATTERN = /(?:\b(?:assert(?:\.[a-zA-Z0-9_$]+)?|expect|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
979
+ const isCommentLine = (str) => /^\s*(?:\/\/|\/\*|\*|#|--|;)/.test(str);
980
+
981
+ const fileAssertions = new Map();
982
+
871
983
  for (let i = 0; i < lines.length; i++) {
872
984
  const line = lines[i];
985
+
986
+ if ((line.startsWith("--- ") || line.startsWith("--- a/")) && !line.startsWith("----")) {
987
+ const orig = line.slice(3).split("\t")[0].trim().replace(/^a\//, "");
988
+ lastOldFile = orig && orig !== "/dev/null" ? orig : null;
989
+ continue;
990
+ }
991
+
873
992
  if ((line.startsWith("+++ ") || line.startsWith("+++ b/") || line.startsWith("+++ /dev/null")) && !line.startsWith("++++")) {
874
993
  const target = line.slice(3).split("\t")[0].trim().replace(/^b\//, "");
875
- currentFile = target && target !== "/dev/null" ? target : null;
876
- currentLineNo = null;
994
+ currentFile = target && target !== "/dev/null" ? target : lastOldFile;
995
+ currentOldLineNo = null;
996
+ currentNewLineNo = null;
877
997
  continue;
878
998
  }
879
999
 
880
- const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
1000
+ const hunkMatch = /^@@ -(\d+)(?:,\d+)? \+(\d+)/.exec(line);
881
1001
  if (hunkMatch) {
882
- currentLineNo = Number(hunkMatch[1]);
1002
+ currentOldLineNo = Number(hunkMatch[1]);
1003
+ currentNewLineNo = Number(hunkMatch[2]);
883
1004
  continue;
884
1005
  }
885
1006
 
@@ -887,34 +1008,81 @@ export function checkTestTampering(diffOrText = "", options = {}) {
887
1008
  continue;
888
1009
  }
889
1010
 
890
- if (line.startsWith("+") && !line.startsWith("+++")) {
1011
+ if (!fileAssertions.has(currentFile)) {
1012
+ fileAssertions.set(currentFile, { removed: [], added: 0 });
1013
+ }
1014
+ const fileStats = fileAssertions.get(currentFile);
1015
+
1016
+ if (line.startsWith("-") && !line.startsWith("---")) {
1017
+ const deletedText = line.slice(1);
1018
+ if (!isCommentLine(deletedText) && ASSERTION_PATTERN.test(deletedText)) {
1019
+ fileStats.removed.push({ line: currentOldLineNo, text: deletedText });
1020
+ }
1021
+ if (currentOldLineNo !== null) currentOldLineNo++;
1022
+ } else if (line.startsWith("+") && !line.startsWith("+++")) {
891
1023
  const addedText = line.slice(1);
1024
+ let isVacuous = false;
892
1025
 
893
1026
  // Check skip injections
894
1027
  for (const skipRule of SKIP_INJECTIONS) {
895
1028
  if (skipRule.pattern.test(addedText)) {
896
1029
  violations.push({
897
1030
  file: currentFile,
898
- line: currentLineNo,
1031
+ line: currentNewLineNo,
899
1032
  type: "TEST_SKIP_INJECTION",
900
- reason: `Test Tamper Guard: ${skipRule.desc} found in ${currentFile}${currentLineNo ? `:${currentLineNo}` : ""}`,
1033
+ reason: `Test Tamper Guard: ${skipRule.desc} found in ${currentFile}${currentNewLineNo ? `:${currentNewLineNo}` : ""}`,
1034
+ });
1035
+ }
1036
+ }
1037
+
1038
+ // Check vacuous assertions
1039
+ for (const vacuousRule of VACUOUS_ASSERTIONS) {
1040
+ if (vacuousRule.pattern.test(addedText)) {
1041
+ isVacuous = true;
1042
+ violations.push({
1043
+ file: currentFile,
1044
+ line: currentNewLineNo,
1045
+ type: "VACUOUS_ASSERTION",
1046
+ reason: `Test Tamper Guard: ${vacuousRule.desc} detected in ${currentFile}${currentNewLineNo ? `:${currentNewLineNo}` : ""}`,
901
1047
  });
902
1048
  }
903
1049
  }
904
1050
 
905
1051
  // Check commented-out assertions
1052
+ let isCommented = false;
906
1053
  if (COMMENTED_ASSERTION.test(line)) {
1054
+ isCommented = true;
907
1055
  violations.push({
908
1056
  file: currentFile,
909
- line: currentLineNo,
1057
+ line: currentNewLineNo,
910
1058
  type: "COMMENTED_ASSERTION",
911
- reason: `Test Tamper Guard: Commented-out test assertion detected in ${currentFile}${currentLineNo ? `:${currentLineNo}` : ""}`,
1059
+ reason: `Test Tamper Guard: Commented-out test assertion detected in ${currentFile}${currentNewLineNo ? `:${currentNewLineNo}` : ""}`,
912
1060
  });
913
1061
  }
914
1062
 
915
- if (currentLineNo !== null) currentLineNo++;
916
- } else if (currentLineNo !== null && !line.startsWith("-") && !line.startsWith("\\")) {
917
- currentLineNo++;
1063
+ // If valid non-vacuous, non-commented assertion is added, increment added count
1064
+ if (!isVacuous && !isCommented && !isCommentLine(addedText) && ASSERTION_PATTERN.test(addedText)) {
1065
+ fileStats.added++;
1066
+ }
1067
+
1068
+ if (currentNewLineNo !== null) currentNewLineNo++;
1069
+ } else if (!line.startsWith("\\")) {
1070
+ if (currentOldLineNo !== null) currentOldLineNo++;
1071
+ if (currentNewLineNo !== null) currentNewLineNo++;
1072
+ }
1073
+ }
1074
+
1075
+ for (const [file, stats] of fileAssertions.entries()) {
1076
+ if (stats.removed.length > stats.added) {
1077
+ const unreplaced = stats.removed.slice(stats.added);
1078
+ for (const item of unreplaced) {
1079
+ violations.push({
1080
+ file,
1081
+ line: item.line,
1082
+ type: "ASSERTION_REMOVAL",
1083
+ reason: `Test Tamper Guard: Assertion removed without replacement in ${file}${item.line ? `:${item.line}` : ""}: "${item.text.trim()}"`,
1084
+ });
1085
+ }
918
1086
  }
919
1087
  }
920
1088
 
@@ -931,9 +1099,9 @@ export function scanDiff(diffTextStr = "", options = {}) {
931
1099
  const findings = [];
932
1100
 
933
1101
  for (const segment of segments) {
934
- const hit = classifyAddedLines(segment.lines.map((l) => l.text).join("\n"));
1102
+ const hit = classifyAddedLines(segment.lines.map((l) => l.text).join("\n"), segment.file);
935
1103
  if (!hit) continue;
936
- const line = segment.file ? locateFindingLine(segment.lines, hit.type) : null;
1104
+ const line = segment.file ? locateFindingLine(segment.lines, hit.type, segment.file) : null;
937
1105
  const at = segment.file ? ` (${segment.file}${line ? `:${line}` : ""})` : "";
938
1106
  findings.push({
939
1107
  severity: hit.severity,
@@ -949,7 +1117,7 @@ export function scanDiff(diffTextStr = "", options = {}) {
949
1117
  // detection for attribution, fall back to the joined text when every file
950
1118
  // came back clean — the cost lands only on diffs with nothing to report.
951
1119
  if (findings.length === 0 && segments.length > 1) {
952
- const hit = classifyAddedLines(segments.flatMap((s) => s.lines.map((l) => l.text)).join("\n"));
1120
+ const hit = classifyAddedLines(segments.flatMap((s) => s.lines.map((l) => l.text)).join("\n"), null);
953
1121
  if (hit) {
954
1122
  findings.push({
955
1123
  severity: hit.severity,
@@ -123,7 +123,7 @@ export function planInit(root = process.cwd(), options = {}) {
123
123
  const tierName = options.tier || FALLBACK_TIER;
124
124
  // An unrecognised name resolves the same way loadConfig() resolves it, so the
125
125
  // scaffolded limits always match what the runtime will later enforce.
126
- const limits = TIER_PROFILES[tierName] || TIER_PROFILES[FALLBACK_TIER];
126
+ const tierPresetLimits = TIER_PROFILES[tierName] || TIER_PROFILES[FALLBACK_TIER];
127
127
 
128
128
  // Preserve existing config if present
129
129
  let existingConfig = {};
@@ -134,6 +134,21 @@ export function planInit(root = process.cwd(), options = {}) {
134
134
  } catch (_) {}
135
135
  }
136
136
 
137
+ // Preserve existing jules.yml if present
138
+ let existingJules = {};
139
+ const existingJulesPath = join(root, ".agent", "jules.yml");
140
+ if (existsSync(existingJulesPath)) {
141
+ try {
142
+ existingJules = parseYaml(readFileSync(existingJulesPath, "utf-8")) || {};
143
+ } catch (_) {}
144
+ }
145
+
146
+ const customLimits = options.limits || existingConfig.limits;
147
+ const isCustomLimits = customLimits && Object.entries(customLimits).some(
148
+ ([k, v]) => v !== undefined && tierPresetLimits[k] !== undefined && Number(v) !== Number(tierPresetLimits[k])
149
+ );
150
+ const limits = isCustomLimits ? { ...tierPresetLimits, ...customLimits } : tierPresetLimits;
151
+
137
152
  const verify = {
138
153
  test: options.testCmd || existingConfig.verify?.test || oracle.candidates.testCmd || "",
139
154
  build: options.buildCmd || existingConfig.verify?.build || oracle.candidates.buildCmd || "",
@@ -143,19 +158,17 @@ export function planInit(root = process.cwd(), options = {}) {
143
158
 
144
159
  const selectedPresets = options.presets || existingConfig.presets || ["nightly-security-audit", "flaky-test-quarantine"];
145
160
 
161
+ const limitsBlock = isCustomLimits
162
+ ? `\nlimits:\n concurrency: ${limits.concurrency}\n daily_tasks: ${limits.daily_tasks}\n stagger_ms: ${limits.stagger_ms}\n diff_kb: ${limits.diff_kb}\n`
163
+ : "";
164
+
146
165
  const configYaml = `# Google Jules Orchestrator Kit Config (v${KIT_VERSION})
147
166
  version: 1
148
167
  provider: ${existingConfig.provider || "jules"}
149
168
  tier: ${tierName}
150
169
  base_branch: ${options.baseBranch || existingConfig.base_branch || "main"}
151
170
  branch_prefix: ${existingConfig.branch_prefix || "agent/"}
152
-
153
- limits:
154
- concurrency: ${limits.concurrency}
155
- daily_tasks: ${limits.daily_tasks}
156
- stagger_ms: ${limits.stagger_ms}
157
- diff_kb: ${limits.diff_kb}
158
-
171
+ ${limitsBlock}
159
172
  verify:
160
173
  test: "${verify.test}"
161
174
  build: "${verify.build}"
@@ -166,10 +179,23 @@ presets:
166
179
  ${selectedPresets.map((p) => ` - ${p}`).join("\n")}
167
180
  `;
168
181
 
169
- const julesYaml = `# Google Jules Agent Compatibility Manifest
170
- version: 1
182
+ const forbiddenPaths = options.forbiddenPaths || existingJules.forbidden_paths || [
183
+ ".github/**",
184
+ "**/secrets/**",
185
+ "**/*.pem",
186
+ "**/lock-manager/**",
187
+ "scripts/jules-self-audit.mjs",
188
+ ".agent/jules.yml",
189
+ ];
190
+ const allowPaths = options.allowPaths || existingJules.allow_paths || [];
191
+
192
+ const julesYaml = `# Google Jules Repository Configuration (Version 2)
193
+ version: 2
171
194
  test_cmd: "${verify.test}"
172
195
  build_cmd: "${verify.build}"
196
+ forbidden_paths:
197
+ ${forbiddenPaths.map((p) => ` - "${p}"`).join("\n")}
198
+ allow_paths: ${allowPaths.length > 0 ? "\n" + allowPaths.map((p) => ` - "${p}"`).join("\n") : "[]"}
173
199
  `;
174
200
 
175
201
  return {