jules-orchestrator-kit 0.38.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/provider.mjs CHANGED
@@ -1,6 +1,60 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join, extname } from "node:path";
2
4
  import { redactSecrets } from "./security.mjs";
3
5
 
6
+ // Extensions node --check can parse as a script. .mjs/.cjs are unambiguous;
7
+ // plain .js is checked as a script too — a stray top-level ESM import would
8
+ // already have failed the Declarative/Mechanical-tier files this gate
9
+ // actually sees, and a false SyntaxError just forces an unnecessary (but
10
+ // harmless) escalation to the primary provider rather than a missed one.
11
+ const SYNTAX_CHECKABLE_EXTS = new Set([".js", ".mjs", ".cjs"]);
12
+
13
+ /**
14
+ * Lists working-tree files (relative paths) touched since the last commit,
15
+ * limited to JS extensions node --check can parse. Deleted files are
16
+ * excluded — there is nothing left on disk to check. Zero-dependency: shells
17
+ * out to `git status`, the same plumbing src/security.mjs and src/git.mjs
18
+ * already rely on.
19
+ */
20
+ function listChangedSourceFiles(root) {
21
+ try {
22
+ const res = spawnSync("git", ["status", "--porcelain=v1", "--no-renames"], {
23
+ cwd: root,
24
+ encoding: "utf-8",
25
+ });
26
+ if (res.status !== 0 || !res.stdout) return [];
27
+ const files = [];
28
+ for (const line of res.stdout.split("\n")) {
29
+ if (!line.trim()) continue;
30
+ const statusCode = line.slice(0, 2);
31
+ if (statusCode.includes("D")) continue;
32
+ const filePath = line.slice(3).trim().replace(/^"|"$/g, "");
33
+ if (SYNTAX_CHECKABLE_EXTS.has(extname(filePath).toLowerCase())) files.push(filePath);
34
+ }
35
+ return files;
36
+ } catch (_) {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Runs the given files through V8's native `node --check` (parses the AST
43
+ * without executing it) and returns the first syntax failure found, or null.
44
+ */
45
+ function findSyntaxError(root, files) {
46
+ for (const file of files) {
47
+ const absPath = join(root, file);
48
+ if (!existsSync(absPath)) continue;
49
+ const res = spawnSync(process.execPath, ["--check", absPath], { encoding: "utf-8" });
50
+ if (res.status !== 0) {
51
+ const stderr = redactSecrets((res.stderr || "").slice(0, 300)).trim();
52
+ return { file, error: stderr || `node --check exited ${res.status}` };
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
4
58
  export const JULES_PRESET = {
5
59
  name: "jules",
6
60
  type: "http",
@@ -40,13 +94,13 @@ export const CODEX_PRESET = {
40
94
  // Google Gemini CLI (`npm i -g @google/gemini-cli`), headless mode. Reads the
41
95
  // prompt from stdin (non-TTY input triggers headless mode without needing a
42
96
  // dangling `-p` flag), auto-approves file/shell actions, and defaults to the
43
- // Gemini 3.6 Flash model — a cheap, fast tier suited for trivial/mechanical
44
- // tasks routed by src/router.mjs. Auth via GEMINI_API_KEY (env, not CLI arg).
97
+ // Gemini 3.7 Flash model (with fallback support) — a cheap, fast tier suited
98
+ // for trivial/mechanical tasks routed by src/router.mjs. Auth via GEMINI_API_KEY.
45
99
  export const GEMINI_PRESET = {
46
100
  name: "gemini-flash",
47
101
  type: "exec",
48
102
  command: "gemini",
49
- args: ["--model", "gemini-3.6-flash", "--approval-mode=yolo", "--output-format", "json"],
103
+ args: ["--model", process.env.GEMINI_FLASH_MODEL || "gemini-3.7-flash", "--approval-mode=yolo", "--output-format", "json"],
50
104
  promptViaStdin: true,
51
105
  };
52
106
 
@@ -246,7 +300,7 @@ export function createProvider(spec = "jules", config = {}) {
246
300
  }
247
301
 
248
302
  const isRepoless = Boolean(task.repoless || ctx.repoless);
249
- const rawRepo = task.source || ctx.source || process.env.JULES_REPO || "";
303
+ const rawRepo = task.source || ctx.source || config.source || process.env.JULES_REPO || "";
250
304
  if (!rawRepo && !ctx.dryRun && providerSpec.name === "jules" && !isRepoless) {
251
305
  throw new Error("Missing connected Jules repository source. Set JULES_REPO or pass source/repoless option.");
252
306
  }
@@ -350,32 +404,74 @@ export function createProvider(spec = "jules", config = {}) {
350
404
  }
351
405
 
352
406
  if (!res.ok) {
353
- const text = await res.text();
407
+ const text = await res.text().catch(() => "");
354
408
  const cleanText = text.slice(0, 500);
355
- const sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
356
- const retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
357
- const retryAfterMs = parseRetryAfter(retryAfterHeader);
358
-
359
- if (res.status === 429) {
360
- pool.markRateLimited(rawToken, retryAfterMs ?? 60000);
361
- lastError = new ProviderRateLimitError(`Provider HTTP Error (429): ${sanitizedText}`, {
362
- retryAfterMs: retryAfterMs ?? 60000,
363
- status: 429,
364
- });
365
- if (pool.size > 1 && attempt < maxAttempts - 1) {
366
- continue;
409
+ let sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
410
+ let retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
411
+ let retryAfterMs = parseRetryAfter(retryAfterHeader);
412
+
413
+ if (res.status === 400 && bodyObj && typeof bodyObj === "object") {
414
+ const lowerText = sanitizedText.toLowerCase();
415
+ if (
416
+ lowerText.includes("deprecated") ||
417
+ lowerText.includes("unrecognized") ||
418
+ lowerText.includes("unknown field") ||
419
+ lowerText.includes("temperature") ||
420
+ lowerText.includes("thinking_budget") ||
421
+ lowerText.includes("top_p")
422
+ ) {
423
+ // Optimistic Schema Degradation: Strip deprecated fields and transparently retry
424
+ delete bodyObj.temperature;
425
+ delete bodyObj.top_p;
426
+ delete bodyObj.top_k;
427
+ if (bodyObj.thinking_budget !== undefined) {
428
+ delete bodyObj.thinking_budget;
429
+ bodyObj.thinking_level = "high";
430
+ }
431
+ body = JSON.stringify(bodyObj);
432
+ try {
433
+ res = await fetch(url, {
434
+ method: providerSpec.method || "POST",
435
+ headers,
436
+ body,
437
+ signal: AbortSignal.timeout(timeoutMs),
438
+ });
439
+ if (!res.ok) {
440
+ // The retry failed too — re-derive the error context from
441
+ // its response instead of reporting the pre-retry 400,
442
+ // which by definition no longer describes the failure.
443
+ const retryText = await res.text().catch(() => "");
444
+ const cleanRetryText = retryText.slice(0, 500);
445
+ sanitizedText = redactSecrets(rawToken ? cleanRetryText.split(rawToken).join("[REDACTED]") : cleanRetryText);
446
+ retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
447
+ retryAfterMs = parseRetryAfter(retryAfterHeader);
448
+ }
449
+ } catch (_) {}
367
450
  }
368
- throw lastError;
369
451
  }
370
452
 
371
- if (res.status >= 500 && res.status < 600) {
372
- throw new ProviderUnavailableError(`Provider HTTP Error (${res.status}): ${sanitizedText}`, {
373
- status: res.status,
374
- retryAfterMs: retryAfterMs ?? undefined,
375
- });
376
- }
453
+ if (!res.ok) {
454
+ if (res.status === 429) {
455
+ pool.markRateLimited(rawToken, retryAfterMs ?? 60000);
456
+ lastError = new ProviderRateLimitError(`Provider HTTP Error (429): ${sanitizedText}`, {
457
+ retryAfterMs: retryAfterMs ?? 60000,
458
+ status: 429,
459
+ });
460
+ if (pool.size > 1 && attempt < maxAttempts - 1) {
461
+ continue;
462
+ }
463
+ throw lastError;
464
+ }
465
+
466
+ if (res.status >= 500 && res.status < 600) {
467
+ throw new ProviderUnavailableError(`Provider HTTP Error (${res.status}): ${sanitizedText}`, {
468
+ status: res.status,
469
+ retryAfterMs: retryAfterMs ?? undefined,
470
+ });
471
+ }
377
472
 
378
- throw new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
473
+ throw new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
474
+ }
379
475
  }
380
476
 
381
477
  pool.recordUsage(rawToken);
@@ -564,6 +660,255 @@ export function createProvider(spec = "jules", config = {}) {
564
660
 
565
661
  throw new Error(`Unsupported provider type: ${providerSpec.type}`);
566
662
  },
663
+
664
+ async getSession(sessionId, ctx = {}) {
665
+ if (!sessionId || typeof sessionId !== "string") {
666
+ throw new TypeError("getSession() requires a valid sessionId string");
667
+ }
668
+ if (!ctx || typeof ctx !== "object") ctx = {};
669
+
670
+ const pool = ctx.tokenPool || config.tokenPool || TokenPool.fromEnv(config);
671
+ const rawToken = pool.getNextToken() || process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
672
+ if (!rawToken && !ctx.dryRun && providerSpec.name === "jules") {
673
+ throw new MissingApiKeyError();
674
+ }
675
+
676
+ if (ctx.dryRun) {
677
+ return {
678
+ id: sessionId,
679
+ status: "active",
680
+ provider: providerSpec.name,
681
+ };
682
+ }
683
+
684
+ if (providerSpec.type === "http") {
685
+ const getSessionUrlTemplate = providerSpec.getSessionUrl || `${providerSpec.url}/${sessionId}`;
686
+ const urlData = { sessionId, ...ctx };
687
+ const url = interpolateString(getSessionUrlTemplate, urlData);
688
+
689
+ const headerData = { ...urlData, token: rawToken };
690
+ const headers = {};
691
+ for (const [k, v] of Object.entries(providerSpec.headers || {})) {
692
+ const val = interpolateString(v, headerData);
693
+ if (val.includes("\r") || val.includes("\n")) {
694
+ throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
695
+ }
696
+ headers[k] = val;
697
+ }
698
+
699
+ const requestedTimeout = Number(ctx.timeoutMs ?? config.timeoutMs ?? providerSpec.timeoutMs ?? 30_000);
700
+ const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? requestedTimeout : 30_000;
701
+ const maxRetries = Number.isInteger(ctx.maxRetries) ? ctx.maxRetries : 3;
702
+ const initialDelayMs = Number.isInteger(ctx.initialDelayMs) ? ctx.initialDelayMs : 500;
703
+
704
+ let lastErr = null;
705
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
706
+ if (attempt > 0) {
707
+ const delay = initialDelayMs * Math.pow(2, attempt - 1);
708
+ await new Promise((resolve) => setTimeout(resolve, delay));
709
+ }
710
+
711
+ let res;
712
+ try {
713
+ res = await fetch(url, {
714
+ method: "GET",
715
+ headers,
716
+ signal: AbortSignal.timeout(timeoutMs),
717
+ });
718
+ } catch (err) {
719
+ if (err.name === "TimeoutError" || err.name === "AbortError" || err.code === "ABORT_ERR") {
720
+ lastErr = new ProviderUnavailableError(`Provider HTTP Timeout (${timeoutMs}ms): ${err.message}`, { status: 504 });
721
+ if (attempt < maxRetries) continue;
722
+ throw lastErr;
723
+ }
724
+ throw err;
725
+ }
726
+
727
+ if (!res.ok) {
728
+ const text = await res.text();
729
+ const cleanText = text.slice(0, 500);
730
+ const sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
731
+
732
+ if (res.status === 404) {
733
+ lastErr = new Error(`Provider HTTP Error (404 Not Found): ${sanitizedText}`);
734
+ lastErr.status = 404;
735
+ if (attempt < maxRetries) continue;
736
+ throw lastErr;
737
+ }
738
+
739
+ if (res.status === 429) {
740
+ const retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
741
+ const retryAfterMs = parseRetryAfter(retryAfterHeader);
742
+ pool.markRateLimited(rawToken, retryAfterMs ?? 60000);
743
+ lastErr = new ProviderRateLimitError(`Provider HTTP Error (429): ${sanitizedText}`, {
744
+ retryAfterMs: retryAfterMs ?? 60000,
745
+ status: 429,
746
+ });
747
+ if (attempt < maxRetries) continue;
748
+ throw lastErr;
749
+ }
750
+
751
+ if (res.status >= 500 && res.status < 600) {
752
+ lastErr = new ProviderUnavailableError(`Provider HTTP Error (${res.status}): ${sanitizedText}`, {
753
+ status: res.status,
754
+ });
755
+ if (attempt < maxRetries) continue;
756
+ throw lastErr;
757
+ }
758
+
759
+ const err = new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
760
+ err.status = res.status;
761
+ throw err;
762
+ }
763
+
764
+ let json;
765
+ try {
766
+ json = await res.json();
767
+ } catch (err) {
768
+ throw new ProviderSchemaError(`Provider Payload Error: Invalid JSON response: ${err.message}`, {
769
+ status: res.status,
770
+ });
771
+ }
772
+
773
+ return {
774
+ id: json.id || json.name || sessionId,
775
+ status: json.state || json.status || "active",
776
+ raw: json,
777
+ };
778
+ }
779
+ }
780
+
781
+ if (providerSpec.type === "exec") {
782
+ return { id: sessionId, status: "completed" };
783
+ }
784
+
785
+ throw new Error(`Unsupported provider type: ${providerSpec.type}`);
786
+ },
787
+
788
+ async approvePlan(sessionId, ctx = {}) {
789
+ if (!sessionId || typeof sessionId !== "string") {
790
+ throw new TypeError("approvePlan() requires a valid sessionId string");
791
+ }
792
+ if (!ctx || typeof ctx !== "object") ctx = {};
793
+
794
+ const pool = ctx.tokenPool || config.tokenPool || TokenPool.fromEnv(config);
795
+ const rawToken = pool.getNextToken() || process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
796
+ if (!rawToken && !ctx.dryRun && providerSpec.name === "jules") {
797
+ throw new MissingApiKeyError();
798
+ }
799
+
800
+ if (ctx.dryRun) {
801
+ return {
802
+ id: sessionId,
803
+ status: "approved",
804
+ approved: true,
805
+ provider: providerSpec.name,
806
+ };
807
+ }
808
+
809
+ if (providerSpec.type === "http") {
810
+ const approvePlanUrlTemplate = providerSpec.approvePlanUrl || `${providerSpec.url}/${sessionId}:approvePlan`;
811
+ const urlData = { sessionId, ...ctx };
812
+ const url = interpolateString(approvePlanUrlTemplate, urlData);
813
+
814
+ const headerData = { ...urlData, token: rawToken };
815
+ const headers = {};
816
+ for (const [k, v] of Object.entries(providerSpec.headers || {})) {
817
+ const val = interpolateString(v, headerData);
818
+ if (val.includes("\r") || val.includes("\n")) {
819
+ throw new Error(`CRITICAL: Header injection attempt detected in header "${k}"`);
820
+ }
821
+ headers[k] = val;
822
+ }
823
+
824
+ const requestedTimeout = Number(ctx.timeoutMs ?? config.timeoutMs ?? providerSpec.timeoutMs ?? 30_000);
825
+ const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 ? requestedTimeout : 30_000;
826
+ const maxRetries = Number.isInteger(ctx.maxRetries) ? ctx.maxRetries : 3;
827
+ const initialDelayMs = Number.isInteger(ctx.initialDelayMs) ? ctx.initialDelayMs : 500;
828
+
829
+ let lastErr = null;
830
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
831
+ if (attempt > 0) {
832
+ const delay = initialDelayMs * Math.pow(2, attempt - 1);
833
+ await new Promise((resolve) => setTimeout(resolve, delay));
834
+ }
835
+
836
+ let res;
837
+ try {
838
+ res = await fetch(url, {
839
+ method: "POST",
840
+ headers,
841
+ body: JSON.stringify(ctx.body || {}),
842
+ signal: AbortSignal.timeout(timeoutMs),
843
+ });
844
+ } catch (err) {
845
+ if (err.name === "TimeoutError" || err.name === "AbortError" || err.code === "ABORT_ERR") {
846
+ lastErr = new ProviderUnavailableError(`Provider HTTP Timeout (${timeoutMs}ms): ${err.message}`, { status: 504 });
847
+ if (attempt < maxRetries) continue;
848
+ throw lastErr;
849
+ }
850
+ throw err;
851
+ }
852
+
853
+ if (!res.ok) {
854
+ const text = await res.text();
855
+ const cleanText = text.slice(0, 500);
856
+ const sanitizedText = redactSecrets(rawToken ? cleanText.split(rawToken).join("[REDACTED]") : cleanText);
857
+
858
+ if (res.status === 404) {
859
+ lastErr = new Error(`Provider HTTP Error (404 Not Found): ${sanitizedText}`);
860
+ lastErr.status = 404;
861
+ if (attempt < maxRetries) continue;
862
+ throw lastErr;
863
+ }
864
+
865
+ if (res.status === 429) {
866
+ const retryAfterHeader = res.headers ? res.headers.get("retry-after") : null;
867
+ const retryAfterMs = parseRetryAfter(retryAfterHeader);
868
+ pool.markRateLimited(rawToken, retryAfterMs ?? 60000);
869
+ lastErr = new ProviderRateLimitError(`Provider HTTP Error (429): ${sanitizedText}`, {
870
+ retryAfterMs: retryAfterMs ?? 60000,
871
+ status: 429,
872
+ });
873
+ if (attempt < maxRetries) continue;
874
+ throw lastErr;
875
+ }
876
+
877
+ if (res.status >= 500 && res.status < 600) {
878
+ lastErr = new ProviderUnavailableError(`Provider HTTP Error (${res.status}): ${sanitizedText}`, {
879
+ status: res.status,
880
+ });
881
+ if (attempt < maxRetries) continue;
882
+ throw lastErr;
883
+ }
884
+
885
+ const err = new Error(`Provider HTTP Error (${res.status}): ${sanitizedText}`);
886
+ err.status = res.status;
887
+ throw err;
888
+ }
889
+
890
+ let json;
891
+ try {
892
+ json = await res.json();
893
+ } catch (_) {
894
+ json = {};
895
+ }
896
+
897
+ return {
898
+ id: json.id || json.name || sessionId,
899
+ status: json.state || "approved",
900
+ approved: true,
901
+ raw: json,
902
+ };
903
+ }
904
+ }
905
+
906
+ if (providerSpec.type === "exec") {
907
+ return { id: sessionId, status: "approved", approved: true };
908
+ }
909
+
910
+ throw new Error(`Unsupported provider type: ${providerSpec.type}`);
911
+ },
567
912
  };
568
913
  }
569
914
 
@@ -630,8 +975,129 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
630
975
  }
631
976
  },
632
977
 
978
+ async getSession(sessionId, ctx = {}) {
979
+ const errors = [];
980
+ for (let i = 0; i < providerList.length; i++) {
981
+ const provider = providerList[i];
982
+ if (typeof provider.getSession !== "function") continue;
983
+ try {
984
+ const res = await provider.getSession(sessionId, ctx);
985
+ return { ...res, _routedProvider: provider.name, _failoverAttempts: i };
986
+ } catch (err) {
987
+ errors.push({ provider: provider.name, error: err });
988
+ const isRecoverable =
989
+ err instanceof ProviderRateLimitError ||
990
+ err instanceof ProviderUnavailableError ||
991
+ (err.status && err.status >= 500 && err.status < 600) ||
992
+ err.status === 429 ||
993
+ err.status === 404;
994
+
995
+ if (i === providerList.length - 1 || !isRecoverable) {
996
+ if (err && typeof err === "object") {
997
+ err._failoverErrors = errors;
998
+ }
999
+ throw err;
1000
+ }
1001
+ }
1002
+ }
1003
+ },
1004
+
1005
+ async approvePlan(sessionId, ctx = {}) {
1006
+ const errors = [];
1007
+ for (let i = 0; i < providerList.length; i++) {
1008
+ const provider = providerList[i];
1009
+ if (typeof provider.approvePlan !== "function") continue;
1010
+ try {
1011
+ const res = await provider.approvePlan(sessionId, ctx);
1012
+ return { ...res, _routedProvider: provider.name, _failoverAttempts: i };
1013
+ } catch (err) {
1014
+ errors.push({ provider: provider.name, error: err });
1015
+ const isRecoverable =
1016
+ err instanceof ProviderRateLimitError ||
1017
+ err instanceof ProviderUnavailableError ||
1018
+ (err.status && err.status >= 500 && err.status < 600) ||
1019
+ err.status === 429 ||
1020
+ err.status === 404;
1021
+
1022
+ if (i === providerList.length - 1 || !isRecoverable) {
1023
+ if (err && typeof err === "object") {
1024
+ err._failoverErrors = errors;
1025
+ }
1026
+ throw err;
1027
+ }
1028
+ }
1029
+ }
1030
+ },
1031
+
633
1032
  validate() {
634
1033
  return providerList.every((p) => typeof p.validate !== "function" || p.validate());
635
1034
  },
636
1035
  };
637
1036
  }
1037
+
1038
+ /**
1039
+ * Wraps a FAST-tier provider so its output is verified before being trusted.
1040
+ * Aggressive cost-router heuristics only stay safe if the cheap model cannot
1041
+ * silently commit broken syntax: a session that returns 200 OK from
1042
+ * `gemini-flash` after truncating a file mid-brace is otherwise
1043
+ * indistinguishable from a genuine success until CI runs.
1044
+ *
1045
+ * After the fast provider's dispatch resolves, this checks the working tree
1046
+ * for `.js`/`.mjs`/`.cjs` files changed since the last commit and parses each
1047
+ * through V8's native `node --check` (syntax only, never executed). A
1048
+ * SyntaxError silently re-dispatches the same task through `complexProvider`
1049
+ * instead of surfacing the broken result — the same "the cheap tier failed,
1050
+ * fall through" contract `createFailoverProvider` already uses for
1051
+ * rate-limits, just triggered by a verified bad output rather than a thrown
1052
+ * error. Sessions with no local working-tree diff (e.g. a remote HTTP
1053
+ * provider used as the fast tier) are a no-op: there is nothing on disk to
1054
+ * check, so the original result passes through unchanged.
1055
+ * @param {object} fastProvider - Provider object (from createProvider) whose output gets verified.
1056
+ * @param {object} complexProvider - Provider object to escalate to on a verified syntax failure.
1057
+ * @param {object} config - Loaded orchestrator config (used to resolve the repo root).
1058
+ * @returns {object} Provider object with the same interface as fastProvider.
1059
+ */
1060
+ export function createSyntaxVerifiedProvider(fastProvider, complexProvider, config = {}) {
1061
+ return {
1062
+ name: fastProvider.name,
1063
+
1064
+ async dispatch(task = {}, ctx = {}) {
1065
+ const result = await fastProvider.dispatch(task, ctx);
1066
+ if (ctx.dryRun) return result;
1067
+
1068
+ const root = ctx.root || config._root || process.cwd();
1069
+ const changedFiles = listChangedSourceFiles(root);
1070
+ if (changedFiles.length === 0) return result;
1071
+
1072
+ const bad = findSyntaxError(root, changedFiles);
1073
+ if (!bad) return result;
1074
+
1075
+ console.warn(
1076
+ `[ROUTER ESCALATION] FAST tier ('${fastProvider.name}') emitted invalid syntax in ${bad.file} (${bad.error}). Escalating to '${complexProvider.name}'.`
1077
+ );
1078
+ const escalated = await complexProvider.dispatch(task, ctx);
1079
+ return {
1080
+ ...escalated,
1081
+ _syntaxEscalated: true,
1082
+ _syntaxEscalationFile: bad.file,
1083
+ _syntaxEscalationReason: bad.error,
1084
+ };
1085
+ },
1086
+
1087
+ validate() {
1088
+ return typeof fastProvider.validate !== "function" || fastProvider.validate();
1089
+ },
1090
+
1091
+ resume(...args) {
1092
+ return fastProvider.resume(...args);
1093
+ },
1094
+
1095
+ getSession(...args) {
1096
+ return fastProvider.getSession(...args);
1097
+ },
1098
+
1099
+ approvePlan(...args) {
1100
+ return fastProvider.approvePlan(...args);
1101
+ },
1102
+ };
1103
+ }