jules-orchestrator-kit 0.42.0 → 0.52.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.
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { git, changedFiles, diffText } from "../git.mjs";
4
- import { resolveRoot } from "../config.mjs";
4
+ import { resolveRoot, isWindowsAbsolutePath } from "../config.mjs";
5
5
 
6
6
  export class CheckpointError extends Error {
7
7
  constructor(message, opts = {}) {
@@ -11,6 +11,29 @@ export class CheckpointError extends Error {
11
11
  }
12
12
  }
13
13
 
14
+ // Checkpoint ids are used verbatim as the snapshot filename under
15
+ // `.agent/state/checkpoints/`. An id that is not a single plain filename —
16
+ // `../…`, `C:\…`, `\\server\share`, or any value carrying a separator — would
17
+ // let a restore read (or a create write) outside that directory. Ids are
18
+ // therefore restricted to one `[A-Za-z0-9]`-led filename component, and the
19
+ // drive/UNC spellings are rejected explicitly on top of the whitelist
20
+ // (`SEC-02` / `P-01`).
21
+ const CHECKPOINT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
22
+
23
+ function assertSafeCheckpointId(id) {
24
+ if (
25
+ typeof id !== "string" ||
26
+ id.length === 0 ||
27
+ isWindowsAbsolutePath(id) ||
28
+ !CHECKPOINT_ID_RE.test(id)
29
+ ) {
30
+ throw new CheckpointError(
31
+ `Invalid checkpoint session id '${id}': expected a plain filename (letters, digits, '.', '_', '-') without path separators.`
32
+ );
33
+ }
34
+ return id;
35
+ }
36
+
14
37
  /**
15
38
  * Ensures checkpoint storage directory exists.
16
39
  */
@@ -29,6 +52,7 @@ export function getCheckpointDir(root = resolveRoot()) {
29
52
  * @returns {object} Checkpoint metadata
30
53
  */
31
54
  export function createCheckpoint(sessionId = `session-${Date.now()}`, options = {}) {
55
+ const safeId = assertSafeCheckpointId(sessionId);
32
56
  const root = options.root || resolveRoot();
33
57
  const dir = getCheckpointDir(root);
34
58
 
@@ -47,7 +71,7 @@ export function createCheckpoint(sessionId = `session-${Date.now()}`, options =
47
71
 
48
72
  const snapshot = {
49
73
  version: 1,
50
- id: sessionId,
74
+ id: safeId,
51
75
  timestamp: new Date().toISOString(),
52
76
  headSha,
53
77
  branch,
@@ -55,7 +79,7 @@ export function createCheckpoint(sessionId = `session-${Date.now()}`, options =
55
79
  diffContent,
56
80
  };
57
81
 
58
- const snapshotPath = join(dir, `${sessionId}.json`);
82
+ const snapshotPath = join(dir, `${safeId}.json`);
59
83
  writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
60
84
 
61
85
  // Keep last 10 checkpoints
@@ -83,6 +107,10 @@ export function restoreCheckpoint(sessionId = "--latest", options = {}) {
83
107
  targetId = list[0].id;
84
108
  }
85
109
 
110
+ // A caller-supplied id becomes a filename here; reject anything that is not
111
+ // a plain filename before `join` can turn it into a path escape (`P-01`).
112
+ targetId = assertSafeCheckpointId(targetId);
113
+
86
114
  const snapshotFile = join(dir, `${targetId}.json`);
87
115
  if (!existsSync(snapshotFile)) {
88
116
  throw new CheckpointError(`Checkpoint snapshot file not found for session '${targetId}'.`);
package/src/perf.mjs ADDED
@@ -0,0 +1,92 @@
1
+ import { monitorEventLoopDelay } from "node:perf_hooks";
2
+ import { execSync } from "node:child_process";
3
+ import { resolveVerify } from "./config.mjs";
4
+
5
+ /**
6
+ * Measures Node.js Event Loop delay during test execution or asynchronous workloads
7
+ * to detect synchronous blocking, catastrophic regex backtracking, or O(n^2) starvation.
8
+ *
9
+ * @param {string|Function} target - Command line string to execute or async/sync function
10
+ * @param {object} [options]
11
+ * @param {string} [options.root=process.cwd()] - Working directory
12
+ * @param {number} [options.maxDelayMs=50] - Maximum allowable p99/max event loop delay in milliseconds
13
+ * @param {number} [options.resolution=10] - Sampling resolution in milliseconds (default: 10)
14
+ * @param {number} [options.timeoutMs=60000] - Process timeout in milliseconds
15
+ * @returns {object} { ok, maxMs, meanMs, p50Ms, p90Ms, p99Ms, minMs, thresholdMs, summary, stdout, stderr, exitCode }
16
+ */
17
+ export function measureEventLoopDelay(target, options = {}) {
18
+ const root = options.root || process.cwd();
19
+ const maxDelayMs = typeof options.maxDelayMs === "number" ? options.maxDelayMs : 50;
20
+ const resolution = options.resolution || 10;
21
+ const timeoutMs = options.timeoutMs || 60000;
22
+
23
+ const histogram = monitorEventLoopDelay({ resolution });
24
+ histogram.enable();
25
+
26
+ let exitCode = 0;
27
+ let stdout = "";
28
+ let stderr = "";
29
+
30
+ const startTime = Date.now();
31
+
32
+ try {
33
+ if (typeof target === "function") {
34
+ target();
35
+ } else {
36
+ const cmd = target || resolveVerify(root).testCmd || "npm test";
37
+ const env = { ...process.env };
38
+ for (const k of Object.keys(env)) {
39
+ if (k.startsWith("NODE_TEST_") || k.startsWith("NODE_CHANNEL_")) {
40
+ delete env[k];
41
+ }
42
+ }
43
+
44
+ try {
45
+ stdout = execSync(cmd, {
46
+ cwd: root,
47
+ env,
48
+ timeout: timeoutMs,
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ encoding: "utf-8",
51
+ });
52
+ } catch (err) {
53
+ exitCode = err.status || 1;
54
+ stdout = err.stdout ? String(err.stdout) : "";
55
+ stderr = err.stderr ? String(err.stderr) : err.message;
56
+ }
57
+ }
58
+ } finally {
59
+ histogram.disable();
60
+ }
61
+
62
+ const durationMs = Date.now() - startTime;
63
+
64
+ // Convert nanoseconds to milliseconds
65
+ const minMs = Math.round((histogram.min / 1e6) * 100) / 100;
66
+ const maxMs = Math.round((histogram.max / 1e6) * 100) / 100;
67
+ const meanMs = Number.isFinite(histogram.mean) ? Math.round((histogram.mean / 1e6) * 100) / 100 : 0;
68
+ const p50Ms = Math.round((histogram.percentile(50) / 1e6) * 100) / 100;
69
+ const p90Ms = Math.round((histogram.percentile(90) / 1e6) * 100) / 100;
70
+ const p99Ms = Math.round((histogram.percentile(99) / 1e6) * 100) / 100;
71
+
72
+ // If p99 or max exceeds threshold, mark as lag violation
73
+ const ok = exitCode === 0 && (p99Ms <= maxDelayMs || maxMs <= maxDelayMs);
74
+
75
+ const summary = `Event Loop Delay: p99=${p99Ms}ms, max=${maxMs}ms, mean=${meanMs}ms (threshold: ${maxDelayMs}ms, duration: ${durationMs}ms)`;
76
+
77
+ return {
78
+ ok,
79
+ minMs,
80
+ maxMs,
81
+ meanMs,
82
+ p50Ms,
83
+ p90Ms,
84
+ p99Ms,
85
+ thresholdMs: maxDelayMs,
86
+ durationMs,
87
+ exitCode,
88
+ stdout,
89
+ stderr,
90
+ summary,
91
+ };
92
+ }
package/src/provider.mjs CHANGED
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join, extname } from "node:path";
4
4
  import { redactSecrets } from "./security.mjs";
5
+ import { resolveWindowsSpawn } from "./git.mjs";
5
6
 
6
7
  // Extensions node --check can parse as a script. .mjs/.cjs are unambiguous;
7
8
  // plain .js is checked as a script too — a stray top-level ESM import would
@@ -509,10 +510,18 @@ export function createProvider(spec = "jules", config = {}) {
509
510
 
510
511
  const processedArgs = filteredArgs.map((arg) => interpolateString(arg, data));
511
512
 
512
- const res = spawnSync(command, processedArgs, {
513
+ // `claude`, `gemini` and `codex` are npm-installed CLI binaries, which on
514
+ // Windows are `.cmd` shims that `spawnSync(..., { shell: false })` cannot
515
+ // start (CreateProcess cannot run a batch file). Route them through
516
+ // cmd.exe with per-argument quoting instead (P-08); on other platforms
517
+ // this is a null and the command spawns directly as before.
518
+ const winSpawn = resolveWindowsSpawn(command, processedArgs, process.env);
519
+
520
+ const res = spawnSync(winSpawn ? winSpawn.file : command, winSpawn ? winSpawn.args : processedArgs, {
513
521
  cwd: config._root || process.cwd(),
514
522
  encoding: "utf-8",
515
523
  shell: false,
524
+ windowsVerbatimArguments: Boolean(winSpawn && winSpawn.verbatim),
516
525
  input: providerSpec.promptViaStdin ? data.prompt : undefined,
517
526
  timeout: providerSpec.timeoutMs || 900000,
518
527
  maxBuffer: 32 * 1024 * 1024,
@@ -682,9 +691,9 @@ export function createProvider(spec = "jules", config = {}) {
682
691
  }
683
692
 
684
693
  if (providerSpec.type === "http") {
685
- const getSessionUrlTemplate = providerSpec.getSessionUrl || `${providerSpec.url}/${sessionId}`;
694
+ const getSessionUrlTemplate = ctx.customUrl || providerSpec.getSessionUrl || `${providerSpec.url}/${sessionId}`;
686
695
  const urlData = { sessionId, ...ctx };
687
- const url = interpolateString(getSessionUrlTemplate, urlData);
696
+ const url = ctx.customUrl || interpolateString(getSessionUrlTemplate, urlData);
688
697
 
689
698
  const headerData = { ...urlData, token: rawToken };
690
699
  const headers = {};
@@ -711,8 +720,9 @@ export function createProvider(spec = "jules", config = {}) {
711
720
  let res;
712
721
  try {
713
722
  res = await fetch(url, {
714
- method: "GET",
723
+ method: ctx.method || "GET",
715
724
  headers,
725
+ body: ctx.body ? JSON.stringify(ctx.body) : undefined,
716
726
  signal: AbortSignal.timeout(timeoutMs),
717
727
  });
718
728
  } catch (err) {
@@ -807,9 +817,9 @@ export function createProvider(spec = "jules", config = {}) {
807
817
  }
808
818
 
809
819
  if (providerSpec.type === "http") {
810
- const approvePlanUrlTemplate = providerSpec.approvePlanUrl || `${providerSpec.url}/${sessionId}:approvePlan`;
820
+ const approvePlanUrlTemplate = ctx.customUrl || providerSpec.approvePlanUrl || `${providerSpec.url}/${sessionId}:approvePlan`;
811
821
  const urlData = { sessionId, ...ctx };
812
- const url = interpolateString(approvePlanUrlTemplate, urlData);
822
+ const url = ctx.customUrl || interpolateString(approvePlanUrlTemplate, urlData);
813
823
 
814
824
  const headerData = { ...urlData, token: rawToken };
815
825
  const headers = {};
@@ -909,6 +919,94 @@ export function createProvider(spec = "jules", config = {}) {
909
919
 
910
920
  throw new Error(`Unsupported provider type: ${providerSpec.type}`);
911
921
  },
922
+
923
+ async listSessions(ctx = {}) {
924
+ if (!ctx || typeof ctx !== "object") ctx = {};
925
+ if (ctx.dryRun) return { sessions: [], raw: {} };
926
+ if (providerSpec.type === "http") {
927
+ const baseUrl = providerSpec.url || "https://jules.googleapis.com/v1alpha/sessions";
928
+ const params = new URLSearchParams();
929
+ if (ctx.pageSize) params.set("pageSize", String(ctx.pageSize));
930
+ if (ctx.pageToken) params.set("pageToken", String(ctx.pageToken));
931
+ const url = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
932
+ const res = await this.getSession("", { ...ctx, customUrl: url });
933
+ return {
934
+ sessions: res?.raw?.sessions || [],
935
+ nextPageToken: res?.raw?.nextPageToken,
936
+ raw: res?.raw || {},
937
+ };
938
+ }
939
+ return { sessions: [], raw: {} };
940
+ },
941
+
942
+ async listActivities(sessionId, ctx = {}) {
943
+ if (!sessionId || typeof sessionId !== "string") {
944
+ throw new TypeError("listActivities() requires a valid sessionId string");
945
+ }
946
+ if (!ctx || typeof ctx !== "object") ctx = {};
947
+ if (ctx.dryRun) return { activities: [], raw: {} };
948
+ if (providerSpec.type === "http") {
949
+ const baseUrl = `${providerSpec.url || "https://jules.googleapis.com/v1alpha/sessions"}/${sessionId}/activities`;
950
+ const params = new URLSearchParams();
951
+ if (ctx.pageSize) params.set("pageSize", String(ctx.pageSize));
952
+ if (ctx.pageToken) params.set("pageToken", String(ctx.pageToken));
953
+ const url = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
954
+ const res = await this.getSession("", { ...ctx, customUrl: url });
955
+ return {
956
+ activities: res?.raw?.activities || [],
957
+ nextPageToken: res?.raw?.nextPageToken,
958
+ raw: res?.raw || {},
959
+ };
960
+ }
961
+ return { activities: [], raw: {} };
962
+ },
963
+
964
+ async archiveSession(sessionId, ctx = {}) {
965
+ if (!sessionId || typeof sessionId !== "string") {
966
+ throw new TypeError("archiveSession() requires a valid sessionId string");
967
+ }
968
+ if (!ctx || typeof ctx !== "object") ctx = {};
969
+ if (ctx.dryRun) return { id: sessionId, archived: true, raw: {} };
970
+ if (providerSpec.type === "http") {
971
+ const url = `${providerSpec.url || "https://jules.googleapis.com/v1alpha/sessions"}/${sessionId}:archive`;
972
+ const res = await this.approvePlan(sessionId, { ...ctx, customUrl: url });
973
+ return { id: sessionId, archived: true, raw: res?.raw || {} };
974
+ }
975
+ return { id: sessionId, archived: true, raw: {} };
976
+ },
977
+
978
+ async deleteSession(sessionId, ctx = {}) {
979
+ if (!sessionId || typeof sessionId !== "string") {
980
+ throw new TypeError("deleteSession() requires a valid sessionId string");
981
+ }
982
+ if (!ctx || typeof ctx !== "object") ctx = {};
983
+ if (ctx.dryRun) return { id: sessionId, deleted: true, raw: {} };
984
+ if (providerSpec.type === "http") {
985
+ const url = `${providerSpec.url || "https://jules.googleapis.com/v1alpha/sessions"}/${sessionId}`;
986
+ const res = await this.getSession("", { ...ctx, customUrl: url, method: "DELETE" });
987
+ return { id: sessionId, deleted: true, raw: res?.raw || {} };
988
+ }
989
+ return { id: sessionId, deleted: true, raw: {} };
990
+ },
991
+
992
+ async listSources(ctx = {}) {
993
+ if (!ctx || typeof ctx !== "object") ctx = {};
994
+ if (ctx.dryRun) return { sources: [], raw: {} };
995
+ if (providerSpec.type === "http") {
996
+ const baseUrl = "https://jules.googleapis.com/v1alpha/sources";
997
+ const params = new URLSearchParams();
998
+ if (ctx.pageSize) params.set("pageSize", String(ctx.pageSize));
999
+ if (ctx.pageToken) params.set("pageToken", String(ctx.pageToken));
1000
+ const url = params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
1001
+ const res = await this.getSession("", { ...ctx, customUrl: url });
1002
+ return {
1003
+ sources: res?.raw?.sources || [],
1004
+ nextPageToken: res?.raw?.nextPageToken,
1005
+ raw: res?.raw || {},
1006
+ };
1007
+ }
1008
+ return { sources: [], raw: {} };
1009
+ },
912
1010
  };
913
1011
  }
914
1012
 
@@ -1029,6 +1127,26 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
1029
1127
  }
1030
1128
  },
1031
1129
 
1130
+ async listSessions(ctx = {}) {
1131
+ return providerList[0].listSessions(ctx);
1132
+ },
1133
+
1134
+ async listActivities(sessionId, ctx = {}) {
1135
+ return providerList[0].listActivities(sessionId, ctx);
1136
+ },
1137
+
1138
+ async archiveSession(sessionId, ctx = {}) {
1139
+ return providerList[0].archiveSession(sessionId, ctx);
1140
+ },
1141
+
1142
+ async deleteSession(sessionId, ctx = {}) {
1143
+ return providerList[0].deleteSession(sessionId, ctx);
1144
+ },
1145
+
1146
+ async listSources(ctx = {}) {
1147
+ return providerList[0].listSources(ctx);
1148
+ },
1149
+
1032
1150
  validate() {
1033
1151
  return providerList.every((p) => typeof p.validate !== "function" || p.validate());
1034
1152
  },
@@ -1099,5 +1217,25 @@ export function createSyntaxVerifiedProvider(fastProvider, complexProvider, conf
1099
1217
  approvePlan(...args) {
1100
1218
  return fastProvider.approvePlan(...args);
1101
1219
  },
1220
+
1221
+ listSessions(...args) {
1222
+ return typeof fastProvider.listSessions === "function" ? fastProvider.listSessions(...args) : { sessions: [] };
1223
+ },
1224
+
1225
+ listActivities(...args) {
1226
+ return typeof fastProvider.listActivities === "function" ? fastProvider.listActivities(...args) : { activities: [] };
1227
+ },
1228
+
1229
+ archiveSession(...args) {
1230
+ return typeof fastProvider.archiveSession === "function" ? fastProvider.archiveSession(...args) : { id: args[0], archived: true };
1231
+ },
1232
+
1233
+ deleteSession(...args) {
1234
+ return typeof fastProvider.deleteSession === "function" ? fastProvider.deleteSession(...args) : { id: args[0], deleted: true };
1235
+ },
1236
+
1237
+ listSources(...args) {
1238
+ return typeof fastProvider.listSources === "function" ? fastProvider.listSources(...args) : { sources: [] };
1239
+ },
1102
1240
  };
1103
1241
  }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, existsSync, appendFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { randomUUID } from "node:crypto";
3
+ import { randomUUID, createHash } from "node:crypto";
4
4
  import { getStateDir, ensureDir } from "./state.mjs";
5
5
  import { redactSecrets } from "./security.mjs";
6
6
 
@@ -162,3 +162,156 @@ export function hydrateMemory(root, options = {}) {
162
162
  return `[LEARNED_REMEDIATIONS_CONTEXT]\nThe following historical failure-and-fix patterns were harvested from previous OODA runs in this repository. Avoid repeating these failure modes:\n\n${items.join("\n\n")}`;
163
163
  }
164
164
 
165
+ /**
166
+ * Creates an OODA Thrash Cycle Breaker.
167
+ * Tracks rolling SHA-256 fingerprints of diff hunks and failure states in repair loops.
168
+ * Trips circuit breaker if an identical patch or failure ping-pong cycle is observed.
169
+ *
170
+ * @param {Object} [opts]
171
+ * @param {number} [opts.maxHistory=10] - Maximum states to retain in ring buffer
172
+ * @param {number} [opts.threshold=2] - Number of occurrences before tripping (default: 2 -> A -> B -> A)
173
+ * @returns {{ recordAttempt: (state: { diff?: string, symptom?: string, targetFiles?: string[] }) => { thrash: boolean, cycleLength?: number, fingerprint: string, occurrences: number, reason?: string }, getHistory: () => string[], reset: () => void }}
174
+ */
175
+ export function createThrashDetector(opts = {}) {
176
+ const maxHistory = opts.maxHistory || 10;
177
+ const threshold = opts.threshold || 2;
178
+ const history = [];
179
+
180
+ function computeStateFingerprint(state = {}) {
181
+ const normDiff = (state.diff || "").trim();
182
+ const normSymptom = (state.symptom || "").trim();
183
+ const normFiles = (state.targetFiles || []).slice().sort().join(",");
184
+ return createHash("sha256")
185
+ .update(`${normDiff}:::${normSymptom}:::${normFiles}`)
186
+ .digest("hex");
187
+ }
188
+
189
+ function recordAttempt(state = {}) {
190
+ const fingerprint = computeStateFingerprint(state);
191
+ history.push(fingerprint);
192
+ if (history.length > maxHistory) {
193
+ history.shift();
194
+ }
195
+
196
+ // Check for duplicate occurrences in history
197
+ const occurrences = history.filter((h) => h === fingerprint).length;
198
+ if (occurrences >= threshold) {
199
+ const firstIdx = history.indexOf(fingerprint);
200
+ const lastIdx = history.lastIndexOf(fingerprint);
201
+ const cycleLength = lastIdx - firstIdx;
202
+
203
+ return {
204
+ thrash: true,
205
+ cycleLength,
206
+ fingerprint,
207
+ occurrences,
208
+ reason: `OODA Thrash Circuit Tripped: State fingerprint ${fingerprint.slice(0, 12)} repeated ${occurrences} times across repair loop (cycle length: ${cycleLength}).`,
209
+ };
210
+ }
211
+
212
+ return {
213
+ thrash: false,
214
+ fingerprint,
215
+ occurrences,
216
+ };
217
+ }
218
+
219
+ return {
220
+ recordAttempt,
221
+ getHistory: () => [...history],
222
+ reset: () => {
223
+ history.length = 0;
224
+ },
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Creates a Whack-a-Mole Test-Oscillation Detector for OODA repair loops.
230
+ * Detects when consecutive repair turns oscillate between different failing test sets
231
+ * (e.g. fixing Test A causes Test B to break, then fixing Test B re-breaks Test A).
232
+ *
233
+ * @param {Object} [opts]
234
+ * @param {number} [opts.maxHistory=8] - Maximum test state signatures to retain
235
+ * @param {number} [opts.threshold=2] - Number of repeating occurrences before triggering
236
+ * @returns {{ recordTestOutcome: (failingTests: string[] | string) => { whackAMole: boolean, cycleLength?: number, oscillatingTests?: string[], occurrences: number, promptDirective?: string, reason?: string }, getHistory: () => string[], reset: () => void }}
237
+ */
238
+ export function createWhackAMoleDetector(opts = {}) {
239
+ const maxHistory = opts.maxHistory || 8;
240
+ const threshold = opts.threshold || 2;
241
+ const history = []; // Array of signature strings
242
+ const testSets = []; // Array of string[]
243
+
244
+ function normalizeTestSet(input) {
245
+ if (!input) return [];
246
+ if (Array.isArray(input)) {
247
+ return [...new Set(input.map((s) => String(s).trim()).filter(Boolean))].sort();
248
+ }
249
+ const str = String(input).trim();
250
+ return str ? [str] : [];
251
+ }
252
+
253
+ function recordTestOutcome(failingInput) {
254
+ const tests = normalizeTestSet(failingInput);
255
+ const signature = tests.join("||") || "__ALL_PASS__";
256
+
257
+ history.push(signature);
258
+ testSets.push(tests);
259
+
260
+ if (history.length > maxHistory) {
261
+ history.shift();
262
+ testSets.shift();
263
+ }
264
+
265
+ if (signature === "__ALL_PASS__") {
266
+ return {
267
+ whackAMole: false,
268
+ occurrences: 0,
269
+ };
270
+ }
271
+
272
+ const occurrences = history.filter((h) => h === signature).length;
273
+ if (occurrences >= threshold) {
274
+ const firstIdx = history.indexOf(signature);
275
+ const lastIdx = history.lastIndexOf(signature);
276
+ const cycleLength = lastIdx - firstIdx;
277
+
278
+ // Extract all distinct failing tests observed in the cycle window
279
+ const cycleTests = new Set();
280
+ for (let i = firstIdx; i <= lastIdx; i++) {
281
+ for (const t of testSets[i]) {
282
+ cycleTests.add(t);
283
+ }
284
+ }
285
+ const oscillatingTests = [...cycleTests];
286
+
287
+ const testSummary = oscillatingTests.length > 0 ? oscillatingTests.join(" <-> ") : "tests";
288
+ const promptDirective = `[WHACK_A_MOLE_WARNING] You are trapped in a local optimization cycle where fixing one test breaks another (${testSummary}). Do not add more conditional edge-case band-aids. Revert recent patches and refactor the core logic cleanly.`;
289
+
290
+ return {
291
+ whackAMole: true,
292
+ cycleLength,
293
+ oscillatingTests,
294
+ occurrences,
295
+ promptDirective,
296
+ reason: `Whack-a-Mole Test Oscillation Detected: Test failure signature repeated across repair turns (${testSummary}).`,
297
+ };
298
+ }
299
+
300
+ return {
301
+ whackAMole: false,
302
+ occurrences,
303
+ };
304
+ }
305
+
306
+ return {
307
+ recordTestOutcome,
308
+ getHistory: () => [...history],
309
+ reset: () => {
310
+ history.length = 0;
311
+ testSets.length = 0;
312
+ },
313
+ };
314
+ }
315
+
316
+
317
+
@@ -45,8 +45,14 @@ export function checkRulesBudget(root = process.cwd(), opts = {}) {
45
45
 
46
46
  try {
47
47
  const content = readFileSync(fullPath, "utf-8");
48
- const charCount = content.length;
49
- const lineCount = content.split("\n").length;
48
+ // A CRLF checkout inflates the character count by one `\r` per line, so
49
+ // the identical rules file passes as LF and fails as CRLF — a false
50
+ // budget violation that depends purely on git's autocrlf setting rather
51
+ // than the content. Normalise line endings before measuring (P-12) so the
52
+ // budget counts the rule text, not the line-ending dialect.
53
+ const normalized = content.replace(/\r\n/g, "\n");
54
+ const charCount = normalized.length;
55
+ const lineCount = normalized.split("\n").length;
50
56
 
51
57
  if (charCount > maxChars) {
52
58
  violations.push({
package/src/scaffold.mjs CHANGED
@@ -250,7 +250,7 @@ export function scaffoldRepoAssets(root = process.cwd(), options = {}) {
250
250
  }
251
251
 
252
252
  if (copyDir(join(KIT_ROOT, ".agent/prompts"), join(agentDir, "prompts"), force) > 0) {
253
- created.push(".agent/prompts/ (Overseer, Bolt, Sentinel, Janitor)");
253
+ created.push(".agent/prompts/ (Overseer, Bolt, Sentinel, Janitor, A11y, Scribe, Spectator, Alchemist)");
254
254
  }
255
255
  if (copyDir(join(KIT_ROOT, ".agent/rules"), join(agentDir, "rules"), force) > 0) {
256
256
  created.push(".agent/rules/");