jules-orchestrator-kit 0.65.0 → 0.66.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
@@ -207,7 +207,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
207
207
  * **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).
208
208
  * **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.
209
209
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
210
- * **Verified Test Suite:** Tested with **1109 unit tests across 154 suites passing in < 15.0s**.
210
+ * **Verified Test Suite:** Tested with **1133 unit tests across 163 suites passing in < 15.0s**.
211
211
 
212
212
  <br/>
213
213
 
package/bin/agentctl.mjs CHANGED
@@ -429,6 +429,9 @@ async function main() {
429
429
  if (p.violations) {
430
430
  p.violations.forEach((v) => console.log(` - Violation: ${v.file} (Rule: ${v.rule})`));
431
431
  }
432
+ if (p.unverified) {
433
+ console.log(` - Unverified: ${p.unverified}`);
434
+ }
432
435
  if (p.setup) {
433
436
  console.log(` - Setup: accepted ${p.setup.length} gate scaffold file(s) this repository did not have yet`);
434
437
  p.setup.forEach((f) => console.log(` ${f}`));
@@ -2667,13 +2670,21 @@ async function main() {
2667
2670
  const taskId = values.task || "unknown";
2668
2671
  const agent = values.agent || "jules";
2669
2672
 
2673
+ const { CONFIRM_AFTER } = await import("../src/memory.mjs");
2670
2674
  const res = harvestFailure(root, { exitCode, logPath, diffText, taskId, agent });
2671
2675
  if (values.json) {
2672
2676
  console.log(JSON.stringify(res, null, 2));
2673
2677
  } else {
2674
2678
  if (res.status === "HARVESTED") {
2675
- console.log(`🌾 Harvested failure candidate: ${res.candidate.trigger}`);
2676
- console.log(` Solution: ${res.candidate.solution}`);
2679
+ // No "Solution:" line any more, because there is no solution: the
2680
+ // repair loop exhausting its budget is the definition of not having
2681
+ // found one. It used to print a fixed sentence here and store it as
2682
+ // if it were a remedy.
2683
+ console.log(`🌾 Recorded failure observation: ${res.candidate.trigger}`);
2684
+ console.log(` Seen ${res.occurrences}× — ${res.confirmed ? "stated as a rule" : `not injected into prompts until it recurs ${CONFIRM_AFTER}×`}`);
2685
+ if (!res.confirmed) {
2686
+ console.log(` Record an actual fix for it with: agentctl learning add "<trigger>" "<solution>"`);
2687
+ }
2677
2688
  } else {
2678
2689
  console.log(`⚠️ Harvest rejected: ${res.reason}`);
2679
2690
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.65.0",
3
+ "version": "0.66.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",
@@ -49,6 +49,7 @@ import { parseCollectedTests } from "../src/ops/test-collection.mjs";
49
49
  import {
50
50
  TEST_PATH_CASES,
51
51
  TAMPER_CANARIES,
52
+ MULTILINE_CANARIES,
52
53
  PREDICATE_MUTANTS,
53
54
  EMPTY_RUN_CANARIES,
54
55
  COUNTED_RUN_CANARIES,
@@ -68,6 +69,9 @@ import {
68
69
  function canaryDiff(c) {
69
70
  const ctx = c.context || "// context";
70
71
  const lines = [`--- a/${c.file}`, `+++ b/${c.file}`, "@@ -1,20 +1,20 @@", ` ${ctx}`];
72
+ // Unchanged lines the change sits inside. An assertion whose keyword is on
73
+ // one of these is the shape the line-level denominator could not see.
74
+ for (const l of c.lead || []) lines.push(` ${l}`);
71
75
  for (const l of c.removed) lines.push(`-${l}`);
72
76
  for (const l of c.added) lines.push(`+${l}`);
73
77
  lines.push(` ${ctx}`);
@@ -130,7 +134,7 @@ const canaryResults = new Map();
130
134
  const silent = [];
131
135
  const noDenominator = [];
132
136
  const noAssertions = [];
133
- for (const c of TAMPER_CANARIES) {
137
+ for (const c of [...TAMPER_CANARIES, ...MULTILINE_CANARIES]) {
134
138
  const res = checkTestTampering(canaryDiff(c));
135
139
  const hit = (res.violations || []).some((v) => v.type === c.expect);
136
140
  canaryResults.set(c.id, hit);
@@ -144,7 +148,7 @@ const canaryResults = new Map();
144
148
  noAssertions.push(`${c.id} (${res.assertionsSeen} assertions parsed)`);
145
149
  }
146
150
  }
147
- add("canaries: every tamper rule still fires", silent.length === 0, silent.length ? silent.join("; ") : `${TAMPER_CANARIES.length} canaries red as required`);
151
+ add("canaries: every tamper rule still fires", silent.length === 0, silent.length ? silent.join("; ") : `${TAMPER_CANARIES.length + MULTILINE_CANARIES.length} canaries red as required`);
148
152
  add("canaries: every finding carries a denominator", noDenominator.length === 0, noDenominator.length ? noDenominator.join(", ") : "inputsSeen > 0 on every hit");
149
153
  add("canaries: assertion rules parsed an assertion", noAssertions.length === 0, noAssertions.length ? noAssertions.join(", ") : "assertionsSeen > 0 on every assertion finding");
150
154
  }
@@ -186,7 +190,7 @@ const canaryResults = new Map();
186
190
  const survivors = [];
187
191
  for (const mutant of PREDICATE_MUTANTS) {
188
192
  let killed = false;
189
- for (const c of TAMPER_CANARIES) {
193
+ for (const c of [...TAMPER_CANARIES, ...MULTILINE_CANARIES]) {
190
194
  // Only canaries the healthy predicate catches can kill a mutant.
191
195
  if (!canaryResults.get(c.id)) continue;
192
196
  const res = checkTestTampering(canaryDiff(c), { isTestPath: mutant.fn });
package/src/engine.mjs CHANGED
@@ -692,6 +692,13 @@ export async function gate(opts = {}) {
692
692
  postTestHash,
693
693
  tamperDetected: testTampered,
694
694
  },
695
+ // Verified by exit code alone. Not a failure, and not an approval either:
696
+ // an unrecognised runner passes on purpose, but the operator has to be
697
+ // able to tell that apart from a suite the gate actually counted.
698
+ ...(collectionFloor.unverified ? { unverified: collectionFloor.note } : {}),
699
+ ...(collectionFloor.count !== null && collectionFloor.count !== undefined
700
+ ? { testsCollected: collectionFloor.count, runner: collectionFloor.runner }
701
+ : {}),
695
702
  });
696
703
  phases.push({
697
704
  phase: "evidence",
@@ -382,6 +382,15 @@ export const INNOCENT_EDITS = [
382
382
  added: ['const calc = require("../src/calc");'],
383
383
  why: "`require(` is an import, not a claim — the loose net must not read it as one",
384
384
  },
385
+ {
386
+ id: "reword-comment-inside-assertion/node",
387
+ file: "test/calc.test.js",
388
+ context: "// arithmetic",
389
+ lead: [" assert.equal(", " add(1, 2),"],
390
+ removed: [" 3, // the answer", " );"],
391
+ added: [" 3, // the correct answer", " );"],
392
+ why: "line comments were never stripped — `copyCode(i)` left `pending` at the comment and the copy after the loop put it back",
393
+ },
385
394
  {
386
395
  id: "rename-helper/node",
387
396
  file: "test/calc.test.js",
@@ -510,3 +519,35 @@ export const COUNTED_RUN_CANARIES = [
510
519
  why: "Go's own `ok <package>` line, which a bare `^ok\\s` confused with TAP's `ok 1 - name`",
511
520
  },
512
521
  ];
522
+
523
+ /**
524
+ * Changes that sit inside an assertion whose keyword never appears in the diff.
525
+ *
526
+ * Counting `+`/`-` lines answered zero here, and nothing among the changed
527
+ * lines looked assertion-shaped either, so the guard reported a clean PASS on
528
+ * a five-element expected list rewritten to one element to match broken
529
+ * output. Measured on a real repository: five green phases, `APPROVED`.
530
+ *
531
+ * The statement machinery that pairs rewrites already assembles context lines
532
+ * together with changed ones. The denominator has to use it.
533
+ */
534
+ export const MULTILINE_CANARIES = [
535
+ {
536
+ id: "expectation/python-multiline",
537
+ file: "tests/test_headers.py",
538
+ context: "# headers",
539
+ lead: [" def test_parse(self):", " self.assertEqual(", " _parse_http_header(x),"],
540
+ removed: [' [("text/xml", {}),', ' ("text/plain", {}),', ' ("more", {})])'],
541
+ added: [' [("text/xml", {"tampered": True})])'],
542
+ expect: "ASSERTION_EXPECTATION_CHANGED",
543
+ },
544
+ {
545
+ id: "expectation/js-multiline",
546
+ file: "test/headers.test.js",
547
+ context: "// headers",
548
+ lead: [" assert.deepStrictEqual(", " parse(input),"],
549
+ removed: [" [{ type: \"a\" }, { type: \"b\" }, { type: \"c\" }]", " );"],
550
+ added: [" [{ type: \"a\" }]", " );"],
551
+ expect: "ASSERTION_EXPECTATION_CHANGED",
552
+ },
553
+ ];
package/src/memory.mjs CHANGED
Binary file
@@ -148,7 +148,24 @@ export function checkCollectionFloor(testResult, opts = {}) {
148
148
  }
149
149
 
150
150
  const { count, runner } = parseCollectedTests(testResult.stdout, testResult.stderr);
151
- if (count === null || count >= minTests) {
151
+
152
+ // Deliberately one-sided: only a *stated* zero fails, because failing on
153
+ // "I could not tell" would break every runner not on the list. But passing
154
+ // and saying nothing makes `echo "all tests passed"` indistinguishable from
155
+ // a real suite, so the absence of evidence is reported as an absence.
156
+ if (count === null) {
157
+ return {
158
+ ok: true,
159
+ count: null,
160
+ runner: null,
161
+ reason: null,
162
+ unverified: true,
163
+ note:
164
+ `The verification command exited 0, but no recognised test runner stated how many tests it ran, ` +
165
+ `so the gate cannot tell a full suite from a command that ran nothing. Verified by exit code alone.`,
166
+ };
167
+ }
168
+ if (count >= minTests) {
152
169
  return { ok: true, count, runner, reason: null };
153
170
  }
154
171
 
package/src/security.mjs CHANGED
@@ -1523,8 +1523,13 @@ function stripComments(text, lang) {
1523
1523
  continue;
1524
1524
  }
1525
1525
 
1526
- if (c === "/" && c2 === "/") { copyCode(i); break; }
1527
- if (lang === "python" && c === "#") { copyCode(i); break; }
1526
+ // `pending = n` is the whole fix. `copyCode(i)` copies the code up to
1527
+ // the comment and leaves `pending` sitting at its start; the
1528
+ // `copyCode(n)` after this loop then copied the comment straight back
1529
+ // in, so no line comment has ever been stripped. Block comments were,
1530
+ // which is why `/* … */` behaved and `// …` did not.
1531
+ if (c === "/" && c2 === "/") { copyCode(i); pending = n; break; }
1532
+ if (lang === "python" && c === "#") { copyCode(i); pending = n; break; }
1528
1533
  if (c === "/" && c2 === "*") {
1529
1534
  copyCode(i);
1530
1535
  state.block = 1;
@@ -1929,6 +1934,9 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
1929
1934
  }
1930
1935
 
1931
1936
  const pairs = [];
1937
+ const pairedOld = new Set();
1938
+ const pairedNew = new Set();
1939
+ const cancelled = new Set();
1932
1940
  for (const [shape, olds] of oldByShape) {
1933
1941
  const news = newByShape.get(shape) || [];
1934
1942
 
@@ -1946,20 +1954,69 @@ function detectExpectationRewrites(file, hunks, stats, violations) {
1946
1954
  const survivingOld = [];
1947
1955
  for (const o of olds) {
1948
1956
  const twin = survivingNew.findIndex((n) => n.canon === o.canon);
1949
- if (twin === -1) survivingOld.push(o);
1950
- else survivingNew.splice(twin, 1);
1957
+ if (twin === -1) {
1958
+ survivingOld.push(o);
1959
+ } else {
1960
+ // Present unchanged on both sides: this assertion did not change,
1961
+ // and the argument-level pass below must not be allowed to pair it
1962
+ // with something else and call that a rewrite.
1963
+ cancelled.add(o);
1964
+ cancelled.add(survivingNew[twin]);
1965
+ survivingNew.splice(twin, 1);
1966
+ }
1951
1967
  }
1952
1968
 
1953
1969
  const k = Math.min(survivingOld.length, survivingNew.length);
1954
1970
  for (let t = 0; t < k; t++) {
1955
1971
  const r = survivingOld[t];
1956
1972
  const a = survivingNew[t];
1973
+ // Whatever this pass decides about a pair — reported, or deliberately
1974
+ // let go as a reorder or a reworded message — is the decision. The
1975
+ // argument-level pass below exists only for statements whose shapes
1976
+ // differ so much that they never met in a bucket here; letting it
1977
+ // re-open a case that was already judged turned a comment edit into a
1978
+ // rewritten expectation.
1979
+ pairedOld.add(r);
1980
+ pairedNew.add(a);
1957
1981
  if (r.canon === a.canon) continue;
1958
1982
  if (differsOnlyInMessage(r.clean, a.clean, lang)) continue;
1959
1983
  pairs.push({ r: r.s, a: a.s });
1960
1984
  }
1961
1985
  }
1962
1986
 
1987
+ // Same assertion, same subject, different expected value.
1988
+ //
1989
+ // Shape pairing compares the statement with its literals blanked, so it
1990
+ // only ever matched assertions whose structure survived the edit. Shrink
1991
+ // a five-element expected list to one element to match broken output and
1992
+ // the two images land in different shape buckets, never pair, and the
1993
+ // rewrite is not reported at all — measured on a real repository, where
1994
+ // it collected five green phases.
1995
+ //
1996
+ // The arguments are the better witness here: when both sides call the
1997
+ // same assertion with the same number of arguments and the *subject*
1998
+ // argument is untouched, what changed is what the test expects of it.
1999
+ for (const r of oldCands) {
2000
+ if (pairedOld.has(r) || cancelled.has(r)) continue;
2001
+ const ra = splitAssertionArgs(r.clean, lang);
2002
+ if (!ra || ra.length < 2) continue;
2003
+ for (const a of newCands) {
2004
+ if (pairedNew.has(a) || cancelled.has(a)) continue;
2005
+ const aa = splitAssertionArgs(a.clean, lang);
2006
+ if (!aa || aa.length !== ra.length) continue;
2007
+ const same = (i) => ra[i].replace(/\s+/g, "") === aa[i].replace(/\s+/g, "");
2008
+ // The subject has to be the same expression, or these are two
2009
+ // different assertions that merely resemble each other.
2010
+ if (!same(0)) continue;
2011
+ if (ra.every((_, i) => same(i))) continue;
2012
+ if (differsOnlyInMessage(r.clean, a.clean, lang)) continue;
2013
+ pairs.push({ r: r.s, a: a.s });
2014
+ pairedOld.add(r);
2015
+ pairedNew.add(a);
2016
+ break;
2017
+ }
2018
+ }
2019
+
1963
2020
  // Zero-context hunk: each image is a single fragment and the assertion
1964
2021
  // keyword may sit outside the hunk entirely. The fragment pair is taken
1965
2022
  // only when both sides normalize to the same shape *and* that shape
@@ -2388,21 +2445,57 @@ export function checkTestTampering(diffOrText = "", options = {}) {
2388
2445
  // understood. `UNREADABLE` is the state that has no business being silent
2389
2446
  // — assertion-shaped lines were present and none of them parsed, which
2390
2447
  // means this repository speaks a dialect the guard does not.
2448
+ // An assertion is a statement, not a line.
2449
+ //
2450
+ // Counting `+`/`-` lines missed the commonest shape in every language with
2451
+ // multi-line calls: `self.assertEqual(` sits on an unchanged context line
2452
+ // and only its argument lines are edited. Nothing among the changed lines
2453
+ // matched an assertion pattern, nothing looked assertion-shaped either, so
2454
+ // the guard reported `assertionsSeen: 0` and — because no line looked
2455
+ // suspicious — a clean PASS. A five-element expected list rewritten to one
2456
+ // element to match broken output sailed through five green phases.
2457
+ //
2458
+ // The statement machinery that already exists for pairing knows better:
2459
+ // it assembles context lines together with changed ones. Ask it.
2460
+ for (const [file, stats] of fileAssertions.entries()) {
2461
+ const lang = langForTestFile(file);
2462
+ let touched = 0;
2463
+ for (const hunk of stats.hunks) {
2464
+ const oldSlice = [];
2465
+ const newSlice = [];
2466
+ for (const L of hunk.lines) {
2467
+ if (L.kind !== "+") oldSlice.push(L);
2468
+ if (L.kind !== "-") newSlice.push(L);
2469
+ }
2470
+ for (const stmts of [assembleStatements(oldSlice, lang), assembleStatements(newSlice, lang)]) {
2471
+ for (const st of stmts) {
2472
+ const changed = (st.removedLines?.length || 0) + (st.addedLines?.length || 0) > 0;
2473
+ if (changed && ASSERTION_PATTERN.test(stripComments(st.text, lang))) touched++;
2474
+ }
2475
+ }
2476
+ }
2477
+ stats.statementAssertions = touched;
2478
+ }
2479
+
2391
2480
  let examined = 0;
2392
2481
  let assertionsSeen = 0;
2393
2482
  const unreadable = [];
2394
2483
  for (const [file, stats] of fileAssertions.entries()) {
2395
2484
  examined += stats.examined;
2396
- assertionsSeen += stats.recognised;
2485
+ assertionsSeen += Math.max(stats.recognised, stats.statementAssertions || 0);
2397
2486
  if (stats.unreadable.length > 0) {
2398
2487
  unreadable.push({ file, count: stats.unreadable.length, samples: stats.unreadable.slice(0, 3) });
2399
2488
  }
2400
2489
  }
2401
2490
 
2491
+ // Changed lines inside a test file, none of them recognisable as part of an
2492
+ // assertion, is not the same as "checked and clean" — it is the state where
2493
+ // this guard has nothing to say. Saying nothing and saying "approved" have
2494
+ // to look different, which is the whole reason `status` exists.
2402
2495
  const status =
2403
2496
  reported.length > 0
2404
2497
  ? "FAIL"
2405
- : assertionsSeen === 0 && unreadable.length > 0
2498
+ : assertionsSeen === 0 && (unreadable.length > 0 || examined > 0)
2406
2499
  ? "UNREADABLE"
2407
2500
  : examined > 0
2408
2501
  ? "PASS"
@@ -2480,6 +2573,26 @@ export function scanDiff(diffTextStr = "", options = {}) {
2480
2573
  }
2481
2574
  }
2482
2575
 
2576
+ // The gate calls `scanDiff`, not `assertTestIntegrity` — so wiring the
2577
+ // dialect warning into the latter meant it reached nobody. The guard
2578
+ // computed `UNREADABLE`, and the operator was shown an unblemished pass.
2579
+ // A boundary that is not reported is not a boundary.
2580
+ if (tamperingRes.status === "UNREADABLE") {
2581
+ const where = (tamperingRes.unreadable || []).map((u) => u.file);
2582
+ const sample = tamperingRes.unreadable?.[0]?.samples?.[0];
2583
+ findings.push({
2584
+ severity: "MEDIUM",
2585
+ type: "TEST_DIALECT_UNREADABLE",
2586
+ file: where[0] ?? null,
2587
+ line: null,
2588
+ description:
2589
+ `Test Tamper Guard: changed ${tamperingRes.inputsSeen} line(s) in ${tamperingRes.filesSeen} test file(s) ` +
2590
+ `and recognised no assertion among them${sample ? ` (e.g. ${JSON.stringify(sample)})` : ""}. ` +
2591
+ `This change was NOT checked for tampering. That is not a failure — an unlisted assertion library is ` +
2592
+ `normal — but it is not an approval either, so it is reported rather than passed silently.`,
2593
+ });
2594
+ }
2595
+
2483
2596
  return {
2484
2597
  ok: secretsOk && edgeRes.ok && crossPkgRes.ok && tamperingRes.ok,
2485
2598
  findings,