jules-orchestrator-kit 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -204,7 +204,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
204
204
  * **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).
205
205
  * **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.
206
206
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
207
- * **Verified Test Suite:** Tested with **940 unit tests across 130 suites passing in < 15.0s**.
207
+ * **Verified Test Suite:** Tested with **957 unit tests across 134 suites passing in < 15.0s**.
208
208
 
209
209
  <br/>
210
210
 
@@ -237,7 +237,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
237
237
  | `doctor` | `agentctl doctor [--probe] [--json]` | Diagnostic check runner. `--probe` additionally starts the configured provider's CLI to confirm it answers, rather than only finding it on `PATH`. | `0` (Healthy), `1` (Failures) |
238
238
  | `queue` | `agentctl queue [--dag] [--concurrency <n>] [--dry-run] [--json]` | Consumes and executes task envelopes in `.agent/jules-queue/` with Kahn's DAG dependency resolution. Non-task files (manifests, `README.md`) are skipped, and `--dry-run` previews without moving anything. | `0` (Complete) |
239
239
  | `swarm` | `agentctl swarm [--json]` | Runs parallel multi-agent swarm across worker slots with PID liveness detection. | `0` (Complete) |
240
- | `check` / `gate` / `audit`| `agentctl check [--mode working-tree] [--fix] [--allow-protected] [--allow-test-modifications] [--json] [--json-report <path>]` | Runs security, secret scanning, rules budget audit, and tiered verification gates (with declarative assertion support) against working tree or branch. | `0` (Approved), `1` (Budget/Arg), `3` (Scope), `4` (Verify), `5` (Diff >75K), `6` (Secret), `8` (Flaky) |
240
+ | `check` / `gate` / `audit`| `agentctl check [--mode working-tree] [--fix] [--allow-protected] [--allow-test-change <kind>] [--json] [--json-report <path>]` | Runs security, secret scanning, rules budget audit, and tiered verification gates (with declarative assertion support) against working tree or branch. | `0` (Approved), `1` (Budget/Arg), `3` (Scope), `4` (Verify), `5` (Diff >75K), `6` (Secret), `8` (Flaky) |
241
241
  | `mutate` / `mutation` | `agentctl mutate [--min-score <n>] [--max-mutants <n>] [--cmd <testCmd>] [--json]` | Runs zero-dependency diff mutation testing harness on changed hunks with operator inversion and safety rollback. | `0` (Passed), `1` (Score Low) |
242
242
  | `coverage` | `agentctl coverage [--min <pct>] [--cmd <testCmd>] [--base <ref>] [--json]` | Runs native zero-dependency V8 diff coverage check against added diff lines. | `0` (Passed), `1` (Low Coverage) |
243
243
  | `probe` / `stability` | `agentctl probe [--repeat <n>] [--min <passRate>] [--cmd <testCmd>] [--json]` | Probes test suite flakiness across N consecutive iterations with oscillation detection. | `0` (Passed), `1` (Flaky) |
package/bin/agentctl.mjs CHANGED
@@ -388,7 +388,12 @@ async function main() {
388
388
  // of spec, which necessarily rewrites what a test expects, hit a
389
389
  // CRITICAL finding at exit 6 with no documented way past it. A guard
390
390
  // with no override is not a guard, it is an outage.
391
+ //
392
+ // This one is the blunt form and turns off all six checks. Prefer
393
+ // `--allow-test-change <kind>`: answering one finding should not
394
+ // silence five other checks nobody looked at.
391
395
  "allow-test-modifications": { type: "boolean" },
396
+ "allow-test-change": { type: "string", multiple: true },
392
397
  json: { type: "boolean", short: "j" },
393
398
  "json-report": { type: "string" },
394
399
  "dry-run": { type: "boolean", short: "d" },
@@ -409,6 +414,7 @@ async function main() {
409
414
  fix: values.fix,
410
415
  allowProtected: values["allow-protected"],
411
416
  allowTestModifications: values["allow-test-modifications"],
417
+ allowTestChanges: values["allow-test-change"],
412
418
  jsonReport: values["json-report"],
413
419
  });
414
420
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents — Google Jules, Claude Code, Codex and Gemini CLI.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/engine.mjs CHANGED
@@ -275,7 +275,11 @@ export async function gate(opts = {}) {
275
275
  }
276
276
 
277
277
  // Phase 3: Diff Secret Scanner & Security Checks
278
- const secretResult = scanDiff(diffStr, { root, allowTestModifications: opts.allowTestModifications === true });
278
+ const secretResult = scanDiff(diffStr, {
279
+ root,
280
+ allowTestModifications: opts.allowTestModifications === true,
281
+ allowTestChanges: opts.allowTestChanges,
282
+ });
279
283
  // A binary file reaches the scanner as one summary line, so its contents were
280
284
  // never looked at — a NUL byte in front of a token was enough to hide it.
281
285
  // Inspect those files directly and fold the verdict in.
package/src/security.mjs CHANGED
@@ -1138,8 +1138,11 @@ function locateFindingLine(lines, type, file = null) {
1138
1138
  // assertion — identical once every literal is blanked out — with different
1139
1139
  // values. That does not distinguish an attack from a deliberate change of
1140
1140
  // spec; nothing can, from a diff alone. This reports rather than decides,
1141
- // and `--allow-test-modifications` is the answer when the new expectation is
1142
- // the correct one.
1141
+ // and `--allow-test-change expectation` is the answer when the new
1142
+ // expectation is the correct one. Narrow on purpose: the blunt
1143
+ // `--allow-test-modifications` turns off the other five checks too, and a
1144
+ // check that can only be answered by disabling its neighbours ends up
1145
+ // disabling its neighbours.
1143
1146
 
1144
1147
  // An assertion that states a *specific* expected value. Counting assertions
1145
1148
  // alone let a test be gutted while looking untouched: swapping
@@ -1627,6 +1630,135 @@ const hasLiteralPlaceholder = (shape) =>
1627
1630
  const collapseWhitespace = (s) => s.replace(/\s+/g, " ").trim();
1628
1631
  const shorten = (s) => (s.length > 160 ? `${s.slice(0, 157)}…` : s);
1629
1632
 
1633
+ /**
1634
+ * Split the argument list of the outermost assertion call in `clean`.
1635
+ *
1636
+ * Comments are already stripped by the caller, so only string state has to be
1637
+ * tracked. Returns null whenever the shape is not confidently understood — a
1638
+ * truncated fragment, an unbalanced hunk, a quoting form not handled here —
1639
+ * because every caller uses this to *suppress* a finding, and failing to
1640
+ * understand a statement must never become a reason to stay quiet about it.
1641
+ *
1642
+ * @param {string} clean - comment-stripped statement text
1643
+ * @param {string} lang
1644
+ * @returns {string[] | null} top-level arguments, trimmed
1645
+ */
1646
+ function splitAssertionArgs(clean, lang) {
1647
+ SPECIFIC_ASSERTION.lastIndex = 0;
1648
+ const m = SPECIFIC_ASSERTION.exec(clean);
1649
+ if (!m) return null;
1650
+
1651
+ let i = m.index + m[0].length; // just past the opening paren
1652
+ let depth = 1;
1653
+ let quote = null;
1654
+ let triple = false;
1655
+ const args = [];
1656
+ let start = i;
1657
+
1658
+ while (i < clean.length) {
1659
+ const c = clean[i];
1660
+
1661
+ if (quote !== null) {
1662
+ if (c === "\\") { i += 2; continue; }
1663
+ if (triple && c === quote && clean[i + 1] === quote && clean[i + 2] === quote) {
1664
+ quote = null; triple = false; i += 3; continue;
1665
+ }
1666
+ if (!triple && c === quote) { quote = null; i += 1; continue; }
1667
+ i += 1;
1668
+ continue;
1669
+ }
1670
+
1671
+ if (c === '"' || c === "'" || c === "`") {
1672
+ if (lang === "python" && clean[i + 1] === c && clean[i + 2] === c) {
1673
+ quote = c; triple = true; i += 3; continue;
1674
+ }
1675
+ quote = c; i += 1; continue;
1676
+ }
1677
+
1678
+ if (c === "(" || c === "[" || c === "{") { depth += 1; i += 1; continue; }
1679
+ if (c === ")" || c === "]" || c === "}") {
1680
+ depth -= 1;
1681
+ if (depth === 0) {
1682
+ args.push(clean.slice(start, i).trim());
1683
+ return args;
1684
+ }
1685
+ i += 1;
1686
+ continue;
1687
+ }
1688
+ if (c === "," && depth === 1) {
1689
+ args.push(clean.slice(start, i).trim());
1690
+ start = i + 1;
1691
+ i += 1;
1692
+ continue;
1693
+ }
1694
+ i += 1;
1695
+ }
1696
+ return null; // never closed: an unbalanced fragment, so no suppression
1697
+ }
1698
+
1699
+ /** One plain string literal and nothing else. */
1700
+ const PURE_STRING_LITERAL = new RegExp(
1701
+ [
1702
+ "^'(?:\\\\.|[^'\\\\])*'$",
1703
+ '^"(?:\\\\.|[^"\\\\])*"$',
1704
+ "^`(?:\\\\.|[^`\\\\])*`$",
1705
+ '^"""[\\s\\S]*"""$',
1706
+ "^'''[\\s\\S]*'''$",
1707
+ ].join("|")
1708
+ );
1709
+
1710
+ function isPureStringLiteral(arg) {
1711
+ if (!arg) return false;
1712
+ return PURE_STRING_LITERAL.test(arg.trim());
1713
+ }
1714
+
1715
+ /**
1716
+ * Argument positions that carry a message for a human rather than an expected
1717
+ * value.
1718
+ *
1719
+ * Trailing, for `assert.equal(got, want, "message")` and
1720
+ * `assert_eq!(a, b, "message")`; leading, for Go's
1721
+ * `t.Errorf("got %d want %d", got, want)`. Two arguments is the classic
1722
+ * `(actual, expected)` shape, so a string in last position *there* is the
1723
+ * expected value: `assert.equal(name, "Alice")` must still be judged when
1724
+ * "Alice" becomes "Bob".
1725
+ */
1726
+ function messageArgIndices(args) {
1727
+ const idx = new Set();
1728
+ if (args.length >= 3 && isPureStringLiteral(args[args.length - 1])) idx.add(args.length - 1);
1729
+ if (args.length >= 2 && isPureStringLiteral(args[0])) idx.add(0);
1730
+ return idx;
1731
+ }
1732
+
1733
+ /**
1734
+ * True when two assertions differ only in text written to be read by a person.
1735
+ *
1736
+ * Rewording the message on a failing assertion is among the most common edits
1737
+ * any test file receives, and it says nothing whatsoever about what the suite
1738
+ * checks. But a message is a literal, so blanking literals made the two
1739
+ * statements the same shape and the pairing reported a rewritten expectation
1740
+ * every time somebody improved the wording of a failure. Firing on that is
1741
+ * how an operator learns to pass the override without reading it.
1742
+ */
1743
+ function differsOnlyInMessage(cleanRemoved, cleanAdded, lang) {
1744
+ const a = splitAssertionArgs(cleanRemoved, lang);
1745
+ const b = splitAssertionArgs(cleanAdded, lang);
1746
+ if (!a || !b || a.length !== b.length || a.length === 0) return false;
1747
+
1748
+ const msgIdx = messageArgIndices(a);
1749
+ if (msgIdx.size === 0) return false;
1750
+
1751
+ let sawDifference = false;
1752
+ for (let i = 0; i < a.length; i++) {
1753
+ if (a[i].replace(/\s+/g, "") === b[i].replace(/\s+/g, "")) continue;
1754
+ // A difference outside a message position, or in a position that stopped
1755
+ // being a plain string, is a real change.
1756
+ if (!msgIdx.has(i) || !isPureStringLiteral(b[i])) return false;
1757
+ sawDifference = true;
1758
+ }
1759
+ return sawDifference;
1760
+ }
1761
+
1630
1762
  /**
1631
1763
  * Pair rewritten expectations across the removed and added images of every
1632
1764
  * hunk of one file, and report each pair.
@@ -1664,14 +1796,14 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
1664
1796
  if (s.removedLines.length === 0) continue;
1665
1797
  const clean = stripComments(s.text, lang);
1666
1798
  if (!isSpecificAssertion(clean)) continue;
1667
- oldCands.push({ s, shape: blankLiterals(clean), canon: clean.replace(/\s+/g, "") });
1799
+ oldCands.push({ s, clean, shape: blankLiterals(clean), canon: clean.replace(/\s+/g, "") });
1668
1800
  }
1669
1801
  const newCands = [];
1670
1802
  for (const s of newStmts) {
1671
1803
  if (s.addedLines.length === 0) continue;
1672
1804
  const clean = stripComments(s.text, lang);
1673
1805
  if (!isSpecificAssertion(clean)) continue;
1674
- newCands.push({ s, shape: blankLiterals(clean), canon: clean.replace(/\s+/g, "") });
1806
+ newCands.push({ s, clean, shape: blankLiterals(clean), canon: clean.replace(/\s+/g, "") });
1675
1807
  }
1676
1808
 
1677
1809
  // The t-th removed candidate of a shape pairs with the t-th added
@@ -1698,11 +1830,32 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
1698
1830
  const pairs = [];
1699
1831
  for (const [shape, olds] of oldByShape) {
1700
1832
  const news = newByShape.get(shape) || [];
1701
- const k = Math.min(olds.length, news.length);
1833
+
1834
+ // Cancel the assertions that are byte-identical on both sides before
1835
+ // aligning anything.
1836
+ //
1837
+ // Reordering two assertions removes both and adds both back unchanged.
1838
+ // Positional alignment then matched the first removed against the first
1839
+ // added — a different assertion — and reported two rewritten
1840
+ // expectations for an edit that changed no expected value at all. The
1841
+ // same happened to an assertion that simply moved within its block.
1842
+ // What is present unchanged on both sides did not change; only the
1843
+ // residue can have been rewritten.
1844
+ const survivingNew = news.slice();
1845
+ const survivingOld = [];
1846
+ for (const o of olds) {
1847
+ const twin = survivingNew.findIndex((n) => n.canon === o.canon);
1848
+ if (twin === -1) survivingOld.push(o);
1849
+ else survivingNew.splice(twin, 1);
1850
+ }
1851
+
1852
+ const k = Math.min(survivingOld.length, survivingNew.length);
1702
1853
  for (let t = 0; t < k; t++) {
1703
- if (olds[t].canon !== news[t].canon) {
1704
- pairs.push({ r: olds[t].s, a: news[t].s });
1705
- }
1854
+ const r = survivingOld[t];
1855
+ const a = survivingNew[t];
1856
+ if (r.canon === a.canon) continue;
1857
+ if (differsOnlyInMessage(r.clean, a.clean, lang)) continue;
1858
+ pairs.push({ r: r.s, a: a.s });
1706
1859
  }
1707
1860
  }
1708
1861
 
@@ -1717,9 +1870,11 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
1717
1870
  const sr = blankLiterals(stripComments(r.text, lang));
1718
1871
  const sa = blankLiterals(stripComments(a.text, lang));
1719
1872
  if (sr === sa && hasLiteralPlaceholder(sr)) {
1720
- const cr = stripComments(r.text, lang).replace(/\s+/g, "");
1721
- const ca = stripComments(a.text, lang).replace(/\s+/g, "");
1722
- if (cr !== ca) pairs.push({ r, a });
1873
+ const clr = stripComments(r.text, lang);
1874
+ const cla = stripComments(a.text, lang);
1875
+ if (clr.replace(/\s+/g, "") !== cla.replace(/\s+/g, "") && !differsOnlyInMessage(clr, cla, lang)) {
1876
+ pairs.push({ r, a });
1877
+ }
1723
1878
  }
1724
1879
  }
1725
1880
  }
@@ -1736,9 +1891,10 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
1736
1891
  `"${shorten(collapseWhitespace(p.r.text))}" became "${shorten(collapseWhitespace(p.a.text))}". ` +
1737
1892
  `A deliberately changed spec looks identical to a test bent to match broken ` +
1738
1893
  `output, and a diff alone cannot tell the two apart, so this is flagged for ` +
1739
- `review rather than assumed. If the new expectation is correct, re-run with ` +
1740
- `--allow-test-modifications (which also silences the skip, vacuous, removal ` +
1741
- `and weakening checks for this diff).`,
1894
+ `review rather than assumed. If the new expectation is the correct one, ` +
1895
+ `re-run with --allow-test-change expectation — which allows exactly this ` +
1896
+ `check and leaves the skip, vacuous, commented, removal and weakening ` +
1897
+ `checks doing their job.`,
1742
1898
  });
1743
1899
 
1744
1900
  // Both sides are accounted for here, so they must not also feed the
@@ -1771,6 +1927,61 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
1771
1927
  return allPairs;
1772
1928
  }
1773
1929
 
1930
+ /**
1931
+ * The tamper checks, by the name an operator uses to allow one of them.
1932
+ *
1933
+ * There was one override for all six, and it was a switch marked "off". A
1934
+ * deliberate change of spec rewrites what a test expects, which is
1935
+ * indistinguishable from bending a test to match broken output — so the honest
1936
+ * answer to that finding is sometimes an override. But reaching for it also
1937
+ * silenced injected `.skip()`, `expect(true).toBe(true)`, commented-out
1938
+ * assertions and outright deletions, none of which the operator had looked at.
1939
+ * The check with the highest firing rate therefore set the ceiling for every
1940
+ * other check in the bundle: the more useful this one became, the more often
1941
+ * it would be used to turn the others off.
1942
+ */
1943
+ export const TAMPER_KINDS = new Map([
1944
+ ["TEST_SKIP_INJECTION", "skip"],
1945
+ ["VACUOUS_ASSERTION", "vacuous"],
1946
+ ["COMMENTED_ASSERTION", "commented"],
1947
+ ["ASSERTION_REMOVAL", "removal"],
1948
+ ["ASSERTION_WEAKENED", "weakening"],
1949
+ ["ASSERTION_EXPECTATION_CHANGED", "expectation"],
1950
+ ]);
1951
+
1952
+ /** Every kind name, for CLI validation and help text. */
1953
+ export const TAMPER_KIND_NAMES = Object.freeze([...new Set(TAMPER_KINDS.values())].sort());
1954
+
1955
+ /**
1956
+ * Which tamper checks this run is allowed to stay quiet about.
1957
+ *
1958
+ * @param {object} options
1959
+ * @param {boolean} [options.allowTestModifications] - the blunt form: all of them.
1960
+ * @param {string|string[]} [options.allowTestChanges] - kind names, comma-separated or an array.
1961
+ * @returns {{ all: boolean, kinds: Set<string>, unknown: string[] }}
1962
+ */
1963
+ export function resolveAllowedTamperKinds(options = {}) {
1964
+ if (options.allowTestModifications === true) {
1965
+ return { all: true, kinds: new Set(TAMPER_KIND_NAMES), unknown: [] };
1966
+ }
1967
+ const raw = options.allowTestChanges;
1968
+ const list = (Array.isArray(raw) ? raw : [raw])
1969
+ .flatMap((v) => String(v == null ? "" : v).split(","))
1970
+ .map((v) => v.trim().toLowerCase())
1971
+ .filter(Boolean);
1972
+
1973
+ if (list.includes("all")) {
1974
+ return { all: true, kinds: new Set(TAMPER_KIND_NAMES), unknown: [] };
1975
+ }
1976
+ const kinds = new Set();
1977
+ const unknown = [];
1978
+ for (const name of list) {
1979
+ if (TAMPER_KIND_NAMES.includes(name)) kinds.add(name);
1980
+ else unknown.push(name);
1981
+ }
1982
+ return { all: false, kinds, unknown };
1983
+ }
1984
+
1774
1985
  /**
1775
1986
  * Detects test file assertion tampering, weakening, or test skips.
1776
1987
  *
@@ -1781,7 +1992,8 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
1781
1992
  */
1782
1993
  export function checkTestTampering(diffOrText = "", options = {}) {
1783
1994
  if (!diffOrText || typeof diffOrText !== "string") return { ok: true, violations: [] };
1784
- if (options.allowTestModifications === true) return { ok: true, violations: [] };
1995
+ const allowed = resolveAllowedTamperKinds(options);
1996
+ if (allowed.all) return { ok: true, violations: [] };
1785
1997
 
1786
1998
  const violations = [];
1787
1999
  const lines = diffOrText.split("\n");
@@ -1987,9 +2199,17 @@ export function checkTestTampering(diffOrText = "", options = {}) {
1987
2199
  }
1988
2200
  }
1989
2201
 
2202
+ // A kind the operator has already looked at and accepted is dropped here
2203
+ // rather than never being computed, so the reasoning above stays one code
2204
+ // path regardless of what any given run allows.
2205
+ const reported =
2206
+ allowed.kinds.size === 0
2207
+ ? violations
2208
+ : violations.filter((v) => !allowed.kinds.has(TAMPER_KINDS.get(v.type)));
2209
+
1990
2210
  return {
1991
- ok: violations.length === 0,
1992
- violations,
2211
+ ok: reported.length === 0,
2212
+ violations: reported,
1993
2213
  };
1994
2214
  }
1995
2215