@gaunt-sloth/core 2.0.0-alpha.3 → 2.0.0-alpha.31

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.
Files changed (271) hide show
  1. package/README.md +52 -20
  2. package/dist/config/colour.d.ts +38 -0
  3. package/dist/config/colour.js +36 -0
  4. package/dist/config/colour.js.map +1 -0
  5. package/dist/config/defaults.d.ts +84 -0
  6. package/dist/config/defaults.js +99 -0
  7. package/dist/config/defaults.js.map +1 -0
  8. package/dist/config/filesystem-tools.d.ts +41 -0
  9. package/dist/config/filesystem-tools.js +56 -0
  10. package/dist/config/filesystem-tools.js.map +1 -0
  11. package/dist/config/jsonc.d.ts +12 -0
  12. package/dist/config/jsonc.js +41 -0
  13. package/dist/config/jsonc.js.map +1 -0
  14. package/dist/config/loader.d.ts +246 -0
  15. package/dist/config/loader.js +1321 -0
  16. package/dist/config/loader.js.map +1 -0
  17. package/dist/config/mouse.d.ts +50 -0
  18. package/dist/config/mouse.js +44 -0
  19. package/dist/config/mouse.js.map +1 -0
  20. package/dist/config/profiles.d.ts +68 -0
  21. package/dist/config/profiles.js +93 -0
  22. package/dist/config/profiles.js.map +1 -0
  23. package/dist/config/providerKeys.d.ts +69 -0
  24. package/dist/config/providerKeys.js +69 -0
  25. package/dist/config/providerKeys.js.map +1 -0
  26. package/dist/config/schema.d.ts +2999 -0
  27. package/dist/config/schema.js +1519 -0
  28. package/dist/config/schema.js.map +1 -0
  29. package/dist/config/shell-policy.d.ts +896 -0
  30. package/dist/config/shell-policy.js +750 -0
  31. package/dist/config/shell-policy.js.map +1 -0
  32. package/dist/config/tool-descriptions.d.ts +211 -0
  33. package/dist/config/tool-descriptions.js +272 -0
  34. package/dist/config/tool-descriptions.js.map +1 -0
  35. package/dist/config/types.d.ts +760 -0
  36. package/dist/config/types.js +13 -0
  37. package/dist/config/types.js.map +1 -0
  38. package/dist/config.d.ts +34 -827
  39. package/dist/config.js +25 -657
  40. package/dist/config.js.map +1 -1
  41. package/dist/constants.d.ts +46 -0
  42. package/dist/constants.js +46 -0
  43. package/dist/constants.js.map +1 -1
  44. package/dist/core/GthAbstractAgent.d.ts +135 -2
  45. package/dist/core/GthAbstractAgent.js +496 -30
  46. package/dist/core/GthAbstractAgent.js.map +1 -1
  47. package/dist/core/GthAgentRunner.d.ts +568 -54
  48. package/dist/core/GthAgentRunner.js +1537 -136
  49. package/dist/core/GthAgentRunner.js.map +1 -1
  50. package/dist/core/GthLangChainAgent.d.ts +115 -0
  51. package/dist/core/GthLangChainAgent.js +644 -17
  52. package/dist/core/GthLangChainAgent.js.map +1 -1
  53. package/dist/core/approvals/annotations.d.ts +122 -0
  54. package/dist/core/approvals/annotations.js +137 -0
  55. package/dist/core/approvals/annotations.js.map +1 -0
  56. package/dist/core/approvals/grants.d.ts +216 -0
  57. package/dist/core/approvals/grants.js +469 -0
  58. package/dist/core/approvals/grants.js.map +1 -0
  59. package/dist/core/approvals/matcher.d.ts +202 -0
  60. package/dist/core/approvals/matcher.js +267 -0
  61. package/dist/core/approvals/matcher.js.map +1 -0
  62. package/dist/core/approvals/mcpSubjects.d.ts +40 -0
  63. package/dist/core/approvals/mcpSubjects.js +99 -0
  64. package/dist/core/approvals/mcpSubjects.js.map +1 -0
  65. package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
  66. package/dist/core/approvals/toolAnnotationSources.js +277 -0
  67. package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
  68. package/dist/core/approvals/toolHost.d.ts +46 -0
  69. package/dist/core/approvals/toolHost.js +108 -0
  70. package/dist/core/approvals/toolHost.js.map +1 -0
  71. package/dist/core/debugCapture.d.ts +75 -0
  72. package/dist/core/debugCapture.js +101 -0
  73. package/dist/core/debugCapture.js.map +1 -0
  74. package/dist/core/gthLeanAgentFactory.d.ts +9 -0
  75. package/dist/core/gthLeanAgentFactory.js +10 -0
  76. package/dist/core/gthLeanAgentFactory.js.map +1 -0
  77. package/dist/core/launchBanner.d.ts +120 -0
  78. package/dist/core/launchBanner.js +418 -0
  79. package/dist/core/launchBanner.js.map +1 -0
  80. package/dist/core/plainToolIndication.d.ts +15 -0
  81. package/dist/core/plainToolIndication.js +174 -0
  82. package/dist/core/plainToolIndication.js.map +1 -0
  83. package/dist/core/reasoningBlocks.d.ts +60 -0
  84. package/dist/core/reasoningBlocks.js +98 -0
  85. package/dist/core/reasoningBlocks.js.map +1 -0
  86. package/dist/core/refusal.d.ts +53 -0
  87. package/dist/core/refusal.js +133 -0
  88. package/dist/core/refusal.js.map +1 -0
  89. package/dist/core/runStats.d.ts +52 -0
  90. package/dist/core/runStats.js +118 -0
  91. package/dist/core/runStats.js.map +1 -0
  92. package/dist/core/shell/ShellCommandFailedError.d.ts +54 -0
  93. package/dist/core/shell/ShellCommandFailedError.js +68 -0
  94. package/dist/core/shell/ShellCommandFailedError.js.map +1 -0
  95. package/dist/core/shell/abstention.d.ts +88 -0
  96. package/dist/core/shell/abstention.js +184 -0
  97. package/dist/core/shell/abstention.js.map +1 -0
  98. package/dist/core/shell/approvalCapture.d.ts +271 -0
  99. package/dist/core/shell/approvalCapture.js +108 -0
  100. package/dist/core/shell/approvalCapture.js.map +1 -0
  101. package/dist/core/shell/approvalStop.d.ts +84 -0
  102. package/dist/core/shell/approvalStop.js +119 -0
  103. package/dist/core/shell/approvalStop.js.map +1 -0
  104. package/dist/core/shell/arity.d.ts +6 -0
  105. package/dist/core/shell/arity.js +20 -6
  106. package/dist/core/shell/arity.js.map +1 -1
  107. package/dist/core/shell/denylist.d.ts +11 -0
  108. package/dist/core/shell/denylist.js +37 -0
  109. package/dist/core/shell/denylist.js.map +1 -0
  110. package/dist/core/shell/escalationSeverity.d.ts +141 -0
  111. package/dist/core/shell/escalationSeverity.js +89 -0
  112. package/dist/core/shell/escalationSeverity.js.map +1 -0
  113. package/dist/core/shell/framing.d.ts +190 -0
  114. package/dist/core/shell/framing.js +633 -0
  115. package/dist/core/shell/framing.js.map +1 -0
  116. package/dist/core/shell/hardline.d.ts +81 -0
  117. package/dist/core/shell/hardline.js +607 -0
  118. package/dist/core/shell/hardline.js.map +1 -0
  119. package/dist/core/shell/negotiation.d.ts +249 -0
  120. package/dist/core/shell/negotiation.js +355 -0
  121. package/dist/core/shell/negotiation.js.map +1 -0
  122. package/dist/core/shell/normalize.d.ts +44 -4
  123. package/dist/core/shell/normalize.js +61 -7
  124. package/dist/core/shell/normalize.js.map +1 -1
  125. package/dist/core/shell/openWorld.d.ts +263 -0
  126. package/dist/core/shell/openWorld.js +1188 -0
  127. package/dist/core/shell/openWorld.js.map +1 -0
  128. package/dist/core/shell/rater.d.ts +935 -0
  129. package/dist/core/shell/rater.js +1473 -0
  130. package/dist/core/shell/rater.js.map +1 -0
  131. package/dist/core/shell/raterModel.d.ts +41 -0
  132. package/dist/core/shell/raterModel.js +51 -0
  133. package/dist/core/shell/raterModel.js.map +1 -0
  134. package/dist/core/shell/rejection.d.ts +69 -0
  135. package/dist/core/shell/rejection.js +38 -0
  136. package/dist/core/shell/rejection.js.map +1 -0
  137. package/dist/core/toolCallRepair/grammar.d.ts +41 -0
  138. package/dist/core/toolCallRepair/grammar.js +116 -0
  139. package/dist/core/toolCallRepair/grammar.js.map +1 -0
  140. package/dist/core/toolCallRepair/index.d.ts +2 -0
  141. package/dist/core/toolCallRepair/index.js +7 -0
  142. package/dist/core/toolCallRepair/index.js.map +1 -0
  143. package/dist/core/toolCallRepair/payload.d.ts +36 -0
  144. package/dist/core/toolCallRepair/payload.js +341 -0
  145. package/dist/core/toolCallRepair/payload.js.map +1 -0
  146. package/dist/core/toolCallRepair/promote.d.ts +45 -0
  147. package/dist/core/toolCallRepair/promote.js +90 -0
  148. package/dist/core/toolCallRepair/promote.js.map +1 -0
  149. package/dist/core/toolDisplay.d.ts +123 -0
  150. package/dist/core/toolDisplay.js +451 -0
  151. package/dist/core/toolDisplay.js.map +1 -0
  152. package/dist/core/toolOutputChannel.d.ts +95 -0
  153. package/dist/core/toolOutputChannel.js +165 -0
  154. package/dist/core/toolOutputChannel.js.map +1 -0
  155. package/dist/core/types.d.ts +294 -12
  156. package/dist/core/types.js.map +1 -1
  157. package/dist/history/historyFormat.d.ts +28 -0
  158. package/dist/history/historyFormat.js +127 -0
  159. package/dist/history/historyFormat.js.map +1 -0
  160. package/dist/history/historyStore.d.ts +198 -0
  161. package/dist/history/historyStore.js +482 -0
  162. package/dist/history/historyStore.js.map +1 -0
  163. package/dist/history/recordSession.d.ts +37 -0
  164. package/dist/history/recordSession.js +56 -0
  165. package/dist/history/recordSession.js.map +1 -0
  166. package/dist/index.d.ts +4 -0
  167. package/dist/index.js +4 -0
  168. package/dist/index.js.map +1 -1
  169. package/dist/providers/anthropic.d.ts +1 -1
  170. package/dist/providers/anthropic.js +5 -10
  171. package/dist/providers/anthropic.js.map +1 -1
  172. package/dist/providers/deepseek.d.ts +1 -1
  173. package/dist/providers/deepseek.js +5 -10
  174. package/dist/providers/deepseek.js.map +1 -1
  175. package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
  176. package/dist/providers/geminiSchemaSanitizer.js +347 -0
  177. package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
  178. package/dist/providers/geminiThinking.d.ts +52 -0
  179. package/dist/providers/geminiThinking.js +72 -0
  180. package/dist/providers/geminiThinking.js.map +1 -0
  181. package/dist/providers/google-genai.d.ts +1 -1
  182. package/dist/providers/google-genai.js +12 -11
  183. package/dist/providers/google-genai.js.map +1 -1
  184. package/dist/providers/groq.d.ts +1 -1
  185. package/dist/providers/groq.js +5 -10
  186. package/dist/providers/groq.js.map +1 -1
  187. package/dist/providers/huggingface.d.ts +25 -0
  188. package/dist/providers/huggingface.js +69 -0
  189. package/dist/providers/huggingface.js.map +1 -0
  190. package/dist/providers/modelCatalog.d.ts +109 -0
  191. package/dist/providers/modelCatalog.js +245 -0
  192. package/dist/providers/modelCatalog.js.map +1 -0
  193. package/dist/providers/modelDiscovery.d.ts +153 -6
  194. package/dist/providers/modelDiscovery.js +295 -41
  195. package/dist/providers/modelDiscovery.js.map +1 -1
  196. package/dist/providers/ollama.d.ts +19 -5
  197. package/dist/providers/ollama.js +60 -52
  198. package/dist/providers/ollama.js.map +1 -1
  199. package/dist/providers/openai.d.ts +1 -1
  200. package/dist/providers/openai.js +39 -10
  201. package/dist/providers/openai.js.map +1 -1
  202. package/dist/providers/openrouter.d.ts +4 -5
  203. package/dist/providers/openrouter.js +20 -35
  204. package/dist/providers/openrouter.js.map +1 -1
  205. package/dist/providers/vertexai.d.ts +1 -1
  206. package/dist/providers/vertexai.js +12 -11
  207. package/dist/providers/vertexai.js.map +1 -1
  208. package/dist/providers/xai.d.ts +1 -1
  209. package/dist/providers/xai.js +5 -10
  210. package/dist/providers/xai.js.map +1 -1
  211. package/dist/runtime/askStructured.d.ts +105 -0
  212. package/dist/runtime/askStructured.js +120 -0
  213. package/dist/runtime/askStructured.js.map +1 -0
  214. package/dist/runtime/conversation.d.ts +60 -0
  215. package/dist/runtime/conversation.js +151 -0
  216. package/dist/runtime/conversation.js.map +1 -0
  217. package/dist/runtime/singleShot.d.ts +25 -5
  218. package/dist/runtime/singleShot.js +95 -36
  219. package/dist/runtime/singleShot.js.map +1 -1
  220. package/dist/runtime/structuredOutput.d.ts +104 -0
  221. package/dist/runtime/structuredOutput.js +393 -0
  222. package/dist/runtime/structuredOutput.js.map +1 -0
  223. package/dist/utils/ProgressIndicator.d.ts +21 -0
  224. package/dist/utils/ProgressIndicator.js +30 -3
  225. package/dist/utils/ProgressIndicator.js.map +1 -1
  226. package/dist/utils/aiignoreUtils.js.map +1 -1
  227. package/dist/utils/binaryOutputUtils.js.map +1 -1
  228. package/dist/utils/consoleUtils.d.ts +52 -0
  229. package/dist/utils/consoleUtils.js +72 -2
  230. package/dist/utils/consoleUtils.js.map +1 -1
  231. package/dist/utils/crashHandler.d.ts +87 -0
  232. package/dist/utils/crashHandler.js +128 -0
  233. package/dist/utils/crashHandler.js.map +1 -0
  234. package/dist/utils/debugDump.d.ts +134 -0
  235. package/dist/utils/debugDump.js +381 -0
  236. package/dist/utils/debugDump.js.map +1 -0
  237. package/dist/utils/debugUtils.d.ts +13 -4
  238. package/dist/utils/debugUtils.js +36 -13
  239. package/dist/utils/debugUtils.js.map +1 -1
  240. package/dist/utils/displayWidth.d.ts +53 -0
  241. package/dist/utils/displayWidth.js +195 -0
  242. package/dist/utils/displayWidth.js.map +1 -0
  243. package/dist/utils/fileUtils.d.ts +24 -2
  244. package/dist/utils/fileUtils.js +54 -12
  245. package/dist/utils/fileUtils.js.map +1 -1
  246. package/dist/utils/llmUtils.d.ts +39 -8
  247. package/dist/utils/llmUtils.js +76 -8
  248. package/dist/utils/llmUtils.js.map +1 -1
  249. package/dist/utils/redactSecrets.d.ts +63 -0
  250. package/dist/utils/redactSecrets.js +286 -0
  251. package/dist/utils/redactSecrets.js.map +1 -0
  252. package/dist/utils/systemPromptNotes.d.ts +225 -0
  253. package/dist/utils/systemPromptNotes.js +340 -0
  254. package/dist/utils/systemPromptNotes.js.map +1 -0
  255. package/dist/utils/systemUtils.d.ts +49 -1
  256. package/dist/utils/systemUtils.js +67 -3
  257. package/dist/utils/systemUtils.js.map +1 -1
  258. package/dist/utils/toolMatching.d.ts +30 -0
  259. package/dist/utils/toolMatching.js +44 -0
  260. package/dist/utils/toolMatching.js.map +1 -0
  261. package/dist/utils/untrustedText.d.ts +66 -0
  262. package/dist/utils/untrustedText.js +80 -0
  263. package/dist/utils/untrustedText.js.map +1 -0
  264. package/package.json +22 -6
  265. package/schema/gsloth-config.schema.json +3134 -0
  266. package/dist/core/shell/allowlist.d.ts +0 -75
  267. package/dist/core/shell/allowlist.js +0 -187
  268. package/dist/core/shell/allowlist.js.map +0 -1
  269. package/dist/core/shell/judge.d.ts +0 -161
  270. package/dist/core/shell/judge.js +0 -261
  271. package/dist/core/shell/judge.js.map +0 -1
@@ -0,0 +1,1321 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * Configuration discovery + the layered load/merge pipeline (global + project layers,
4
+ * format fall-through JSON → JSONC → JS → MJS, schema validation, deep-merge with defaults).
5
+ * Extracted from the former `config.ts` god-file; behaviour is unchanged.
6
+ */
7
+ import { GSLOTH_DIR, GSLOTH_SETTINGS_DIR, USER_PROJECT_CONFIG_JS, USER_PROJECT_CONFIG_JSON, USER_PROJECT_CONFIG_JSONC, USER_PROJECT_CONFIG_MJS, USER_PROJECT_CONFIG_TS, } from '#src/constants.js';
8
+ import { StatusLevel } from '#src/core/types.js';
9
+ import { displayDebug, displayError, displayInfo, displayWarning, setConsoleLevel, } from '#src/utils/consoleUtils.js';
10
+ import { findApprovalsGrammarIssues, findApprovalsRaterProfiles, findDeprecatedConfigIssues, findUnknownTopLevelKeys, formatConfigValidationError, formatDeprecatedConfigIssues, isRecordConfig, rawGthConfigSchema, unresolvedRaterProfileMessage, validateRawGthConfig, } from '#src/config/schema.js';
11
+ import { parseJsonc } from '#src/config/jsonc.js';
12
+ import { isMissingProviderKeyError, MissingProviderKeyError } from '#src/config/providerKeys.js';
13
+ import { getGslothConfigReadPath, importExternalFile } from '#src/utils/fileUtils.js';
14
+ import { getGlobalGslothConfigReadPath } from '#src/utils/globalConfigUtils.js';
15
+ import { env, error, exit, getCurrentWorkDir, isStdoutTTY, isTTY, setProjectDir, setUseColour, } from '#src/utils/systemUtils.js';
16
+ import { resolveUseColour } from '#src/config/colour.js';
17
+ import { resolveUseMouse } from '#src/config/mouse.js';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { dirname, resolve } from 'node:path';
21
+ import { DEFAULT_CONFIG } from '#src/config/defaults.js';
22
+ /**
23
+ * Validate (and normalize) a freshly loaded raw config layer (global or project)
24
+ * against {@link rawGthConfigSchema}, the single source of truth for the on-disk
25
+ * config shape.
26
+ *
27
+ * Steps, in order:
28
+ * 1. Deprecated-shape reject (GS2-28): a removed pre-2.0 shape — a top-level command key
29
+ * or a deprecated `*Provider*` name (root + per-command), detected by
30
+ * {@link findDeprecatedConfigIssues} — is a HARD error naming the canonical replacement +
31
+ * migration path, then exit. 2.0 dropped back-compat coercion, so these fail rather than
32
+ * remap. Runs FIRST so a deprecated name never merely surfaces as an unknown-key warning.
33
+ * 1b. Approvals rule-grammar reject (EXT-71): a bare string in `allow`/`deny`/`escalate`, or a
34
+ * configured `mcpServers` key named `*`, detected by {@link findApprovalsGrammarIssues} — a
35
+ * HARD error, before the parse so its message (which shows the object form of the string the
36
+ * user wrote) is the only one they see.
37
+ * 2. Unknown top-level keys: warn (do NOT fail) so likely typos are surfaced while
38
+ * forward-compatible / extension keys still pass through untouched.
39
+ * 3. Schema parse: on a genuine type mismatch on a known field, emit a friendly,
40
+ * path-scoped error and exit (matching the loader's existing invalid-config
41
+ * behaviour). Validation is shape-only — the loose schema preserves unknown keys,
42
+ * so the original `raw` is returned unchanged on success.
43
+ *
44
+ * @param raw The freshly loaded config layer (read-only here).
45
+ * @param sourceLabel Human-readable source name for messages (e.g. the filename).
46
+ */
47
+ function validateRawConfigLayer(raw, sourceLabel) {
48
+ // Only an object config can carry deprecated/unknown keys; a null/array/primitive config skips
49
+ // the scans (they'd throw a raw TypeError) and falls to safeParse, which emits a clean
50
+ // "expected object" error + exit — never a coercion to {} (which would wrongly pass).
51
+ if (isRecordConfig(raw)) {
52
+ const deprecatedIssues = findDeprecatedConfigIssues(raw);
53
+ if (deprecatedIssues.length > 0) {
54
+ displayError(`Invalid configuration in ${sourceLabel}:\n${formatDeprecatedConfigIssues(deprecatedIssues)}`);
55
+ exit(1);
56
+ // Unreachable past exit(1) in production; keeps the mocked-exit test path from falling
57
+ // through into the schema parse below.
58
+ return raw;
59
+ }
60
+ // EXT-71 — the rule-grammar errors that must be seen BEFORE the schema parse: a bare string in
61
+ // a rule list (whose message shows the object form of that same string) and a reserved `*`
62
+ // MCP server name. Checked in the same place as the deprecated scan, and in `gth config
63
+ // validate` too, so the validator can never green-light a config a real run refuses.
64
+ const grammarIssues = findApprovalsGrammarIssues(raw);
65
+ if (grammarIssues.length > 0) {
66
+ displayError(`Invalid configuration in ${sourceLabel}:\n${formatDeprecatedConfigIssues(grammarIssues)}`);
67
+ exit(1);
68
+ return raw;
69
+ }
70
+ const unknownKeys = findUnknownTopLevelKeys(raw);
71
+ if (unknownKeys.length > 0) {
72
+ displayWarning(`Unknown top-level config ${unknownKeys.length === 1 ? 'key' : 'keys'} in ${sourceLabel}: ` +
73
+ `${unknownKeys.join(', ')}. ${unknownKeys.length === 1 ? 'It is' : 'They are'} kept as-is ` +
74
+ 'but ignored by Gaunt Sloth; check for typos.');
75
+ }
76
+ }
77
+ const result = rawGthConfigSchema.safeParse(raw);
78
+ if (!result.success) {
79
+ displayError(`Invalid configuration in ${sourceLabel}:\n${formatConfigValidationError(result.error)}`);
80
+ exit(1);
81
+ // Unreachable past exit(1) in production; in specs exit() is mocked, so returning here keeps
82
+ // a shape-invalid config from falling through into the profile check below.
83
+ return raw;
84
+ }
85
+ // CFG-26 — `approvals.rater` STRICT resolution (GS2-62): a named profile that does not
86
+ // resolve to a real profile config is a hard error, never a silent fallback to the main model.
87
+ // Checked HERE rather than in the zod schema on purpose — resolution needs the filesystem and
88
+ // `schema.ts` must stay pure (it also feeds `z.toJSONSchema`).
89
+ if (isRecordConfig(raw)) {
90
+ for (const ref of findApprovalsRaterProfiles(raw)) {
91
+ if (!resolveIdentityProfileConfigPath(ref.profile)) {
92
+ displayError(`Invalid configuration in ${sourceLabel}:\n` +
93
+ ` - ${ref.path}: ${unresolvedRaterProfileMessage(ref)}`);
94
+ exit(1);
95
+ return raw;
96
+ }
97
+ }
98
+ }
99
+ return raw;
100
+ }
101
+ /**
102
+ * Project config file lookup order, highest precedence first. JSON wins, then its `.jsonc`
103
+ * spelling (GS2-69 — same {@link parseJsonc} parse either way), then the `configure()`-style
104
+ * module formats (JS → MJS → TS). Used to pick THE config within a dir.
105
+ */
106
+ const PROJECT_CONFIG_FORMATS = [
107
+ USER_PROJECT_CONFIG_JSON,
108
+ USER_PROJECT_CONFIG_JSONC,
109
+ USER_PROJECT_CONFIG_JS,
110
+ USER_PROJECT_CONFIG_MJS,
111
+ USER_PROJECT_CONFIG_TS,
112
+ ];
113
+ /**
114
+ * GS2-69 — the lookup order for the two JSON-family filenames wherever they are probed as a
115
+ * pair (the global `~/.gsloth` lookup and its read-side mirror). `.json` first, so it wins
116
+ * when both exist — matching {@link PROJECT_CONFIG_FORMATS}.
117
+ */
118
+ const JSON_CONFIG_FILENAMES = [
119
+ USER_PROJECT_CONFIG_JSON,
120
+ USER_PROJECT_CONFIG_JSONC,
121
+ ];
122
+ /**
123
+ * True when `path` belongs to the JSONC-parsing branch (a `.json` OR `.jsonc` file, GS2-69) as
124
+ * opposed to the `configure()`-module importer. Single-sources the run-path gate so an explicit
125
+ * `-c foo.jsonc` can never fall through to the module importer again.
126
+ */
127
+ function isJsonConfigPath(path) {
128
+ return path.endsWith('.json') || path.endsWith('.jsonc');
129
+ }
130
+ /**
131
+ * Dir-aware version of {@link getGslothConfigReadPath} for ancestor dirs during the up-tree
132
+ * walk. Mirrors its `.gsloth/.gsloth-settings[/<profile>]/<filename>` resolution but against an
133
+ * explicit `dir` instead of the cwd, falling back to `<dir>/<filename>`. Implemented with
134
+ * `node:path`/`node:fs` directly (no `fileUtils` round-trip) so the cwd level can keep
135
+ * delegating to the original cwd-bound resolver.
136
+ */
137
+ function resolveProjectConfigPathInDir(dir, filename, identityProfileRaw) {
138
+ const identityProfile = identityProfileRaw?.trim();
139
+ const gslothDirPath = resolve(dir, GSLOTH_DIR);
140
+ if (existsSync(gslothDirPath)) {
141
+ const gslothSettingsPath = resolve(gslothDirPath, GSLOTH_SETTINGS_DIR);
142
+ const configPath = identityProfile
143
+ ? resolve(gslothSettingsPath, identityProfile, filename)
144
+ : resolve(gslothSettingsPath, filename);
145
+ if (existsSync(configPath)) {
146
+ return configPath;
147
+ }
148
+ }
149
+ return resolve(dir, filename);
150
+ }
151
+ /**
152
+ * Resolve where a config `filename` would live for the given base dir, composing with
153
+ * `identityProfile`. The cwd level delegates to the existing cwd-bound
154
+ * {@link getGslothConfigReadPath} (preserving its behaviour and test seams); ancestor dirs use
155
+ * {@link resolveProjectConfigPathInDir}.
156
+ */
157
+ function resolveConfigPath(baseDir, filename, identityProfile) {
158
+ return baseDir === getCurrentWorkDir()
159
+ ? getGslothConfigReadPath(filename, identityProfile)
160
+ : resolveProjectConfigPathInDir(baseDir, filename, identityProfile);
161
+ }
162
+ /**
163
+ * Yield each directory to search during config discovery, from cwd up to (and INCLUDING) the stop
164
+ * boundary — a dir containing `.git` (the git root), the user's home dir, or the filesystem root,
165
+ * whichever comes first (the dir at the boundary is itself searched, then ascent stops). Single-
166
+ * sources the up-tree boundary so {@link findProjectConfigPath} and
167
+ * {@link resolveIdentityProfileConfigPath} can never drift on where the walk starts or stops.
168
+ */
169
+ function* walkConfigSearchDirs() {
170
+ const home = homedir();
171
+ let dir = getCurrentWorkDir();
172
+ for (;;) {
173
+ yield dir;
174
+ const parent = dirname(dir);
175
+ if (existsSync(resolve(dir, '.git')) || dir === home || parent === dir) {
176
+ break;
177
+ }
178
+ dir = parent;
179
+ }
180
+ }
181
+ /**
182
+ * Find THE project config by walking up from cwd toward a stop boundary, returning the FIRST
183
+ * match (first-match-win: nearest dir, then format precedence within that dir — NOT a merged
184
+ * stack). Detection ({@link hasProjectConfig}/{@link hasAnyConfig}) and loading ({@link initConfig})
185
+ * both go through this, so they can never disagree.
186
+ *
187
+ * Stop boundary — the dir is SEARCHED, then ascent stops at: a dir containing `.git` (the git
188
+ * root), the user's home dir, or the filesystem root — whichever comes first. So a config IN the
189
+ * git root (or home) is found; a config ABOVE it is not.
190
+ *
191
+ * A `customConfigPath` override wins outright (no walking).
192
+ *
193
+ * NOTE (identity profile): with an `identityProfile` set, each dir's per-format resolver
194
+ * ({@link resolveConfigPath}) tries the profile path `.gsloth/.gsloth-settings/<profile>/<file>`
195
+ * but FALLS BACK to the plain `<dir>/<file>` when the profile file is absent. So a match here does
196
+ * NOT prove the named profile itself has a config — it may be a plain (non-profile) config. Use
197
+ * {@link resolveIdentityProfileConfigPath} when you need to know a profile specifically resolved.
198
+ *
199
+ * @returns the matched `{ dir, path }`, or `undefined` when no project config exists within the
200
+ * boundary.
201
+ */
202
+ export function findProjectConfigPath(commandLineConfigOverrides) {
203
+ if (commandLineConfigOverrides.customConfigPath) {
204
+ return existsSync(commandLineConfigOverrides.customConfigPath)
205
+ ? {
206
+ dir: dirname(commandLineConfigOverrides.customConfigPath),
207
+ path: commandLineConfigOverrides.customConfigPath,
208
+ }
209
+ : undefined;
210
+ }
211
+ // Walk up: search each dir, then stop at the boundary (git root / home / fs root).
212
+ for (const dir of walkConfigSearchDirs()) {
213
+ for (const filename of PROJECT_CONFIG_FORMATS) {
214
+ const candidate = resolveConfigPath(dir, filename, commandLineConfigOverrides.identityProfile);
215
+ if (existsSync(candidate)) {
216
+ return { dir, path: candidate };
217
+ }
218
+ }
219
+ }
220
+ return undefined;
221
+ }
222
+ /**
223
+ * CFG-26 — the read-side validator's fs-backed hook, so `gth config validate`
224
+ * ({@link collectConfigValidationLayers}) enforces the SAME `approvals.rater` existence
225
+ * rule the loader hard-exits on. `schema.ts` stays pure; the filesystem knowledge lives here.
226
+ */
227
+ const RAW_CONFIG_VALIDATION_OPTIONS = {
228
+ resolveProfile: (profile) => resolveIdentityProfileConfigPath(profile) !== undefined,
229
+ };
230
+ /**
231
+ * STRICT existence check for an EXPLICITLY-named identity profile: does
232
+ * `.gsloth/.gsloth-settings/<identityProfile>/<config>` resolve to a real config file anywhere in
233
+ * the same up-tree search {@link findProjectConfigPath} walks? Returns the resolved profile config
234
+ * path (nearest dir, then format precedence) when the profile has its OWN config, `undefined`
235
+ * otherwise.
236
+ *
237
+ * Unlike {@link findProjectConfigPath}, it matches ONLY the profile-specific path — it NEVER falls
238
+ * through to a plain `<dir>/<config>` and NEVER falls back to the global config. That strictness is
239
+ * the whole point: it lets a caller distinguish "this named profile really exists" from "a bare
240
+ * config happens to be present / a global config exists," a distinction the loader's fall-through
241
+ * deliberately blurs.
242
+ *
243
+ * PURE PREDICATE — never throws, never calls `exit` (contrast the loader's interactive-CLI
244
+ * `exit(1)` safety net in {@link initConfig}). So batch/eval code (e.g. `gth eval --judge <profile>`
245
+ * and BATCH-12's identity matrix) can pre-check an explicitly-requested profile and raise its OWN
246
+ * catchable error / graceful exit code instead of dying on an uncatchable `process.exit`. A
247
+ * blank/whitespace-only name counts as "no profile" → `undefined`.
248
+ *
249
+ * @param identityProfile The explicitly-requested identity profile name.
250
+ * @returns The resolved profile config path, or `undefined` when the profile has no config.
251
+ */
252
+ export function resolveIdentityProfileConfigPath(identityProfile) {
253
+ const profile = identityProfile?.trim();
254
+ if (!profile) {
255
+ return undefined;
256
+ }
257
+ for (const dir of walkConfigSearchDirs()) {
258
+ const profileDir = resolve(dir, GSLOTH_DIR, GSLOTH_SETTINGS_DIR, profile);
259
+ for (const filename of PROJECT_CONFIG_FORMATS) {
260
+ const candidate = resolve(profileDir, filename);
261
+ if (existsSync(candidate)) {
262
+ return candidate;
263
+ }
264
+ }
265
+ }
266
+ return undefined;
267
+ }
268
+ /**
269
+ * Loads the global gsloth config (if present) from the global `~/.gsloth` folder.
270
+ *
271
+ * Precedence support: the returned raw config is intended to act as the BASE that the
272
+ * project config (and CLI overrides) merge on top of, so any value here is the lowest
273
+ * user-controlled layer (still above {@link DEFAULT_CONFIG}).
274
+ *
275
+ * Lookup order within the global folder, first match wins:
276
+ * `.gsloth.config.json` -> `.gsloth.config.jsonc` -> `.gsloth.config.js` -> `.gsloth.config.mjs`
277
+ *
278
+ * Absence of every variant is a no-op: returns `undefined` so behaviour is unchanged.
279
+ *
280
+ * NOTE: secrets (API keys) may live in this file; this function must never log its
281
+ * contents. Only non-sensitive diagnostics (the resolved path / parse failure) are emitted.
282
+ *
283
+ * @returns The raw global config object, or `undefined` when no global config exists.
284
+ */
285
+ export async function loadGlobalRawConfig() {
286
+ // JSON/JSONC first (the must-have formats; `.json` wins when both exist — GS2-69).
287
+ for (const filename of JSON_CONFIG_FILENAMES) {
288
+ const jsonPath = getGlobalGslothConfigReadPath(filename);
289
+ if (existsSync(jsonPath)) {
290
+ try {
291
+ const parsed = parseJsonc(readFileSync(jsonPath, 'utf8'), `${filename} (global)`);
292
+ return validateRawConfigLayer(parsed, `${filename} (global)`);
293
+ }
294
+ catch (e) {
295
+ displayDebug(e instanceof Error ? e : String(e));
296
+ displayWarning(`Failed to read global config from ${jsonPath}, ignoring it.`);
297
+ return undefined;
298
+ }
299
+ }
300
+ }
301
+ // Then JS / MJS variants (dynamic import of a `configure()` module).
302
+ for (const filename of [USER_PROJECT_CONFIG_JS, USER_PROJECT_CONFIG_MJS]) {
303
+ const modulePath = getGlobalGslothConfigReadPath(filename);
304
+ if (existsSync(modulePath)) {
305
+ try {
306
+ const imported = await importExternalFile(modulePath);
307
+ const configured = await imported.configure();
308
+ return validateRawConfigLayer(configured, `${filename} (global)`);
309
+ }
310
+ catch (e) {
311
+ displayDebug(e instanceof Error ? e : String(e));
312
+ displayWarning(`Failed to read global config from ${modulePath}, ignoring it.`);
313
+ return undefined;
314
+ }
315
+ }
316
+ }
317
+ return undefined;
318
+ }
319
+ /**
320
+ * Deep-merges a loaded global raw config UNDER the given project raw config, so the
321
+ * project config wins on conflicting keys. When no global config exists this is a no-op
322
+ * and the original project config is returned unchanged.
323
+ */
324
+ async function applyGlobalConfigBase(projectRawConfig) {
325
+ const globalRawConfig = await loadGlobalRawConfig();
326
+ if (!globalRawConfig) {
327
+ return projectRawConfig;
328
+ }
329
+ return deepMerge(globalRawConfig, projectRawConfig);
330
+ }
331
+ /**
332
+ * GS2-41 — hard cap on the `extends` chain length: a belt-and-suspenders backstop BEHIND the
333
+ * name-cycle guard. Even if a base name somehow failed to register in the visited chain, a chain
334
+ * this long is a misconfiguration and must fail fast rather than recurse without bound.
335
+ */
336
+ const MAX_EXTENDS_CHAIN_DEPTH = 50;
337
+ /**
338
+ * GS2-73 — the typed failure the `extends` traversal ({@link resolveExtendsChain}) raises on a
339
+ * cycle, a missing base, an over-deep chain, or an unreadable base. It carries the SAME clear,
340
+ * user-facing message the run path prints, so the two consumers can translate one shared failure
341
+ * into their own convention WITHOUT the traversal being forked or the checks duplicated:
342
+ * - the run path ({@link resolveConfigExtends}) → `displayError` + `exit(1)` (hard-fail a run),
343
+ * - the read path ({@link validateConfig}) → a `not-ok` layer with this message (collect, never
344
+ * `exit`), so `gth config validate` mirrors what a real run would hit (GS2-29 invariant).
345
+ */
346
+ class ConfigExtendsError extends Error {
347
+ }
348
+ /**
349
+ * GS2-41 — resolve a named profile's `extends` inheritance into a single composed raw config,
350
+ * riding the SAME GS2-1 deep-merge the config LAYERS use (NO second merge engine). When the given
351
+ * profile config declares `extends: "<base>"`, the base profile's config resolves FIRST
352
+ * (recursively — a base may itself extend another, so base-of-base resolves first), then this
353
+ * profile's own fields merge on top with last-wins semantics: the child overrides the base, nested
354
+ * objects merge, arrays REPLACE except the additive-array fields (`allowDirs`, `aiignore.patterns`
355
+ * and the three `approvals` rule lists, see {@link isAdditiveArrayField}) which accumulate
356
+ * base+child. The `extends` key itself is consumed and never leaks into the composed output.
357
+ *
358
+ * A config WITHOUT `extends` is returned UNCHANGED — every non-inheriting config (the vast
359
+ * majority) is untouched and behaves exactly as before.
360
+ *
361
+ * Composition is CONFINED to the profile-dir layer: it produces the single raw config that then
362
+ * acts as the project-file layer the global config underlays and CLI flags overlay, preserving
363
+ * GS2-33's outer precedence `CLI flags > profile (base+child composed) > global > defaults`. It is
364
+ * therefore invoked in {@link initConfig} on the loaded project/profile config BEFORE
365
+ * {@link applyGlobalConfigBase}.
366
+ *
367
+ * The base profile is discovered with {@link resolveIdentityProfileConfigPath} (the SAME strict
368
+ * up-tree profile walk `--profile` uses), so `extends` names a profile exactly as a user selects
369
+ * one; a name with no config dir is a hard, clearly-named error.
370
+ *
371
+ * CYCLE GUARD: the chain of profile NAMES is tracked (seeded with the selected profile's own name);
372
+ * because `extends` is single-valued the chain is linear, so a repeated name — `A extends B extends
373
+ * A`, or a self-extend — is an unambiguous cycle and fails fast with a clear error NAMING the cycle,
374
+ * never infinite-looping / stack-overflowing. A hard {@link MAX_EXTENDS_CHAIN_DEPTH} cap backstops
375
+ * it regardless of how the base path was derived.
376
+ *
377
+ * @param rawConfig the just-loaded, schema-validated raw config that MAY declare `extends`.
378
+ * @param profileLabel the selected profile's own name (for cycle detection + messages); undefined
379
+ * for a plain (non-profile) project config.
380
+ */
381
+ export async function resolveConfigExtends(rawConfig, profileLabel) {
382
+ try {
383
+ return await composeExtends(rawConfig, profileLabel);
384
+ }
385
+ catch (e) {
386
+ // GS2-73 — the traversal now RAISES a {@link ConfigExtendsError} rather than exiting inline, so
387
+ // the read side ({@link validateConfig}) can report the same failure without terminating. On the
388
+ // RUN path we translate it back to the original behaviour: print the clear message and exit(1).
389
+ if (e instanceof ConfigExtendsError) {
390
+ displayError(e.message);
391
+ exit(1);
392
+ // Unreachable past exit(1) in production; LOAD-BEARING under the specs' mocked exit so the
393
+ // caller never observes a silently-composed config after a cycle/missing base.
394
+ throw new Error('Unexpected error occurred.');
395
+ }
396
+ throw e;
397
+ }
398
+ }
399
+ /**
400
+ * GS2-73 — seed the `extends` chain from the selected profile's own name and run the throwing
401
+ * traversal ({@link resolveExtendsChain}). Shared by BOTH consumers so the walk and its
402
+ * cycle/missing-base checks live in ONE place: the run-path {@link resolveConfigExtends} (which
403
+ * translates a {@link ConfigExtendsError} into `displayError` + `exit`) and the read-path
404
+ * {@link validateConfig} (which records it as a not-ok layer). Propagates the typed error to its
405
+ * caller; the caller owns the reporting convention.
406
+ */
407
+ async function composeExtends(rawConfig, profileLabel) {
408
+ const seed = profileLabel?.trim();
409
+ return resolveExtendsChain(rawConfig, seed ? [seed] : []);
410
+ }
411
+ /**
412
+ * The recursive worker for {@link resolveConfigExtends}. `chain` is the ordered list of profile
413
+ * names already being resolved (the selected profile first, then each `extends` base as it is
414
+ * descended into) — used both for the name-based cycle guard and to render the cycle in the error.
415
+ *
416
+ * GS2-73 — on any hard failure (cycle, over-deep chain, missing base, unreadable base) it RAISES a
417
+ * {@link ConfigExtendsError} rather than printing + `exit`ing inline, so the same traversal serves
418
+ * both the run path and the read-side `validateConfig` (each translates the error its own way).
419
+ */
420
+ async function resolveExtendsChain(rawConfig, chain) {
421
+ const baseName = typeof rawConfig.extends === 'string' ? rawConfig.extends.trim() : undefined;
422
+ if (!baseName) {
423
+ return rawConfig;
424
+ }
425
+ // Cycle guard (name-based): a base already present in the chain we are resolving loops back on
426
+ // itself. Fail fast, naming the cycle — never recurse without bound. GS2-73 — raise the shared
427
+ // {@link ConfigExtendsError}; the caller (run path vs. `validateConfig`) owns how it is surfaced.
428
+ if (chain.includes(baseName)) {
429
+ throw new ConfigExtendsError(`Profile inheritance cycle detected: ${[...chain, baseName].join(' -> ')}. ` +
430
+ `A profile's "extends" chain must not refer back to itself.`);
431
+ }
432
+ // Depth backstop behind the name guard — a chain this long can only be a misconfiguration.
433
+ if (chain.length >= MAX_EXTENDS_CHAIN_DEPTH) {
434
+ throw new ConfigExtendsError(`Profile inheritance chain exceeds the maximum depth of ${MAX_EXTENDS_CHAIN_DEPTH}: ` +
435
+ `${[...chain, baseName].join(' -> ')}. This is almost certainly a misconfiguration.`);
436
+ }
437
+ const basePath = resolveIdentityProfileConfigPath(baseName);
438
+ if (!basePath) {
439
+ const from = chain.length > 0 ? ` (referenced from "${chain[chain.length - 1]}")` : '';
440
+ throw new ConfigExtendsError(`Profile "${baseName}" referenced by "extends"${from} was not found: no config file in ` +
441
+ `${GSLOTH_DIR}/${GSLOTH_SETTINGS_DIR}/${baseName}/ (checked ${PROJECT_CONFIG_FORMATS.join(', ')}).`);
442
+ }
443
+ let baseRaw;
444
+ try {
445
+ baseRaw = await readRawConfigAtPath(basePath);
446
+ }
447
+ catch (e) {
448
+ displayDebug(e instanceof Error ? e : String(e));
449
+ throw new ConfigExtendsError(`Failed to read base profile "${baseName}" from ${basePath}.`);
450
+ }
451
+ // Validate the base layer exactly as the project layer is validated (deprecated-shape reject,
452
+ // unknown-key warn, type-mismatch fail) so a broken base surfaces loudly rather than silently.
453
+ const validatedBase = validateRawConfigLayer(baseRaw, `${baseName} (extends base)`);
454
+ // Resolve the base's OWN extends first (base-of-base first), THEN merge this profile's delta on
455
+ // top via the existing GS2-1 deep-merge (child = source, so it wins; additive-array fields at the
456
+ // config root accumulate).
457
+ const resolvedBase = await resolveExtendsChain(validatedBase, [...chain, baseName]);
458
+ // Consume `extends` so it never leaks into the composed output, then merge child over base.
459
+ const childDelta = { ...rawConfig };
460
+ delete childDelta.extends;
461
+ return deepMerge(resolvedBase, childDelta);
462
+ }
463
+ /**
464
+ * ORDERING INVARIANT (GS2-11): detection ({@link hasProjectConfig}/{@link hasAnyConfig}) MUST run
465
+ * before {@link initConfig} in a given process. Both resolve cwd-level candidates via
466
+ * `getGslothConfigReadPath`, which reads `getProjectDir()`; {@link initConfig} clears `projectDir`
467
+ * at the start of its run, so detection stays cwd-correct as long as it precedes initConfig (it
468
+ * does: startSession calls hasAnyConfig before any initConfig, and the ACP/agent path calls
469
+ * initConfig directly without detection). Calling detection AFTER an initConfig with a changed cwd
470
+ * in a long-lived process would read a stale projectDir (currently unreachable). If that call
471
+ * order is ever introduced, decouple discovery's cwd-branch from `getProjectDir()`.
472
+ */
473
+ /**
474
+ * Returns true when a project-level config file (json/jsonc/js/mjs) exists for the given
475
+ * overrides. Honours `customConfigPath` and the active identity profile so the check
476
+ * matches exactly what {@link initConfig} would attempt to load.
477
+ *
478
+ * This is the project half of CFG-10's "is any config present?" detection; the global
479
+ * half is {@link loadGlobalRawConfig} (used by {@link hasAnyConfig}).
480
+ */
481
+ export function hasProjectConfig(commandLineConfigOverrides) {
482
+ return findProjectConfigPath(commandLineConfigOverrides) !== undefined;
483
+ }
484
+ /**
485
+ * CFG-10 — true when ANY usable configuration is present, either a project config file
486
+ * (json/jsonc/js/mjs) or a standalone global config (`~/.gsloth/.gsloth.config.*`). When this
487
+ * returns false the caller should run the first-run dialog instead of erroring.
488
+ *
489
+ * Reuses CFG-8's project + global detection so the two paths can never disagree.
490
+ */
491
+ export async function hasAnyConfig(commandLineConfigOverrides) {
492
+ if (hasProjectConfig(commandLineConfigOverrides)) {
493
+ return true;
494
+ }
495
+ return (await loadGlobalRawConfig()) !== undefined;
496
+ }
497
+ /**
498
+ * CFG-37 — the layered value of the top-level `tui` config key, read BEFORE a session picks its
499
+ * surface. `chat`/`code` choose between the Ink TUI and the readline session in the app's
500
+ * `startSession` dispatcher, and each surface then loads its own config via {@link initConfig} — so
501
+ * at the moment of the choice there is no resolved {@link GthConfig} to consult, and this is the
502
+ * seam that supplies the one key the choice needs.
503
+ *
504
+ * Layering matches a run: the discovered PROJECT layer wins over the GLOBAL one, and a layer that
505
+ * does not set `tui` defers to the next rather than overriding it with `undefined`. A scalar needs
506
+ * no deep merge, so this reads the two layers and picks — it does not fork the merge engine.
507
+ *
508
+ * QUIET and fail-soft for the layers it reads ITSELF: it does not validate them and does not
509
+ * `exit` on a malformed one, because the caller runs moments before {@link initConfig}, which
510
+ * validates every layer and reports the very same problem — warning twice about one file is worse
511
+ * than not warning here. A failed read resolves to `undefined`, i.e. "nobody set it", and the
512
+ * surface auto-detects exactly as it does for a run with no config.
513
+ *
514
+ * ONE EXCEPTION, and it is deliberate: a `tui` may be INHERITED through a GS2-41 profile `extends`
515
+ * chain, so this walks that chain via the SHARED {@link resolveConfigExtends} rather than forking
516
+ * it — and that traversal owns its own reporting. It validates each base layer (so a base's own
517
+ * unknown-key warning can appear here as well as from `initConfig`) and hard-`exit`s on a cycle, a
518
+ * missing base or a malformed base. What the user sees is unchanged — `initConfig` exits on the
519
+ * same chain with the same message a moment later — but it now happens EARLIER, at surface
520
+ * selection rather than at config load. Forking the walk to silence it would mean a second
521
+ * inheritance engine; ignoring `extends` would mean silently dropping an inherited `tui`.
522
+ *
523
+ * Ordering: this is DETECTION, so per the GS2-11 invariant above it must run before any
524
+ * {@link initConfig} in the process — as it does, alongside {@link hasAnyConfig} in `startSession`.
525
+ */
526
+ export async function loadConfiguredTui(commandLineConfigOverrides) {
527
+ const projectTui = await readProjectConfiguredTui(commandLineConfigOverrides);
528
+ if (projectTui !== undefined) {
529
+ return projectTui;
530
+ }
531
+ const globalRaw = await loadGlobalRawConfigUnvalidated();
532
+ const globalTui = globalRaw?.raw.tui;
533
+ return typeof globalTui === 'boolean' ? globalTui : undefined;
534
+ }
535
+ /** The PROJECT layer's `tui`, or undefined when there is no project config or it does not set it. */
536
+ async function readProjectConfiguredTui(commandLineConfigOverrides) {
537
+ const discovered = findProjectConfigPath(commandLineConfigOverrides);
538
+ if (!discovered) {
539
+ return undefined;
540
+ }
541
+ let raw;
542
+ try {
543
+ raw = await readRawConfigAtPath(discovered.path);
544
+ }
545
+ catch (e) {
546
+ // Quiet by design — see the note on loadConfiguredTui. initConfig reports this same file.
547
+ displayDebug(e instanceof Error ? e : String(e));
548
+ return undefined;
549
+ }
550
+ // GS2-41 — a named profile may inherit `tui` from the profile it extends, so compose the chain
551
+ // the way a run does, for EVERY config format. `initConfig` resolves `extends` on both of its
552
+ // branches (the JSON one below and {@link tryModuleConfig}), and `validateConfig` walks it with
553
+ // no format gate at all — so a format gate here would answer `undefined` for a `.js`/`.mjs`
554
+ // profile whose `tui` is inherited while the run composes it, which is precisely the
555
+ // reader-vs-run divergence this seam exists to prevent.
556
+ //
557
+ // Deliberately NOT inside the try above: on a cycle / missing base / malformed base,
558
+ // `resolveConfigExtends` reports the problem and calls `exit(1)`, which ends the process — a
559
+ // catch could not suppress that, and the sentinel throw past it is load-bearing under the specs'
560
+ // mocked `exit` (the convention used throughout this file), so swallowing it would let a test
561
+ // observe a silent fall-through production can never reach.
562
+ if (typeof raw.extends === 'string') {
563
+ raw = await resolveConfigExtends(raw, commandLineConfigOverrides.identityProfile);
564
+ }
565
+ return typeof raw.tui === 'boolean' ? raw.tui : undefined;
566
+ }
567
+ /**
568
+ * Initialize configuration by loading from available config files
569
+ * @returns The loaded GthConfig
570
+ */
571
+ export async function initConfig(commandLineConfigOverrides) {
572
+ if (commandLineConfigOverrides.customConfigPath &&
573
+ !existsSync(commandLineConfigOverrides.customConfigPath)) {
574
+ throw new Error(`Provided manual config "${commandLineConfigOverrides.customConfigPath}" does not exist`);
575
+ }
576
+ // Clear the project root BEFORE discovery. Discovery and detection must resolve against cwd,
577
+ // and the up-tree walk itself goes through getGslothConfigReadPath -> getProjectDir(); clearing
578
+ // first guarantees getProjectDir() falls back to cwd during the walk, even on a SECOND initConfig
579
+ // call in a long-lived process (ACP server) or across tests where a stale projectDir would
580
+ // otherwise poison the walk and miss the real config.
581
+ setProjectDir(undefined);
582
+ // Discover the project config location: a customConfigPath wins outright, otherwise walk up
583
+ // from cwd to the stop boundary (see findProjectConfigPath). Detection and loading share this
584
+ // resolver, and the discovered dir becomes the base for the per-format cascade below.
585
+ const discovered = findProjectConfigPath(commandLineConfigOverrides);
586
+ const baseDir = discovered?.dir ?? getCurrentWorkDir();
587
+ // Set the project root for post-config, project-relative artifact resolution (guidelines,
588
+ // prompts, .gsloth-settings, outputs). up-tree and --config both set it here; a global-only /
589
+ // no-config run leaves it undefined so those artifacts stay cwd-bound (see getProjectDir).
590
+ // Safe for the in-function load below: when discovered.dir === cwd getProjectDir() is unchanged,
591
+ // and when it is an ancestor resolveConfigPath takes its explicit-dir branch (never getProjectDir).
592
+ setProjectDir(discovered?.dir);
593
+ // GS2-69 — prefer the DISCOVERED path when it is a JSON-family file, so a discovered
594
+ // `.gsloth.config.jsonc` reaches the parseJsonc branch below (re-resolving only the `.json`
595
+ // name here would miss it). When discovery found a module config (or nothing), fall back to
596
+ // resolving the `.json` name — it won't exist, so the module fall-through below is unchanged.
597
+ const discoveredJsonConfigPath = discovered && isJsonConfigPath(discovered.path) ? discovered.path : undefined;
598
+ const jsonConfigPath = commandLineConfigOverrides.customConfigPath ??
599
+ discoveredJsonConfigPath ??
600
+ resolveConfigPath(baseDir, USER_PROJECT_CONFIG_JSON, commandLineConfigOverrides.identityProfile);
601
+ // CFG-8 — when no project config file of any format exists (anywhere up-tree), fall back to a
602
+ // standalone global config (loaded alone) before erroring. Project config still takes
603
+ // precedence: this branch only runs when there is no project file to apply the global under.
604
+ if (!discovered) {
605
+ // GS2-62 — an EXPLICITLY named identity profile (`-i <name>` / eval `--judge <name>`) that
606
+ // discovered no project config must NOT silently fall back to the global config. Doing so is a
607
+ // false-green trap: `gth -i typo …` would run under the GLOBAL model while appearing to use the
608
+ // named profile (in an authorization/eval context that hides a real misconfiguration). Fail
609
+ // loudly instead. Gated on a non-empty identityProfile, so the CFG-8 no-profile global fallback
610
+ // just below is UNTOUCHED — a run with no profile still loads the global exactly as before.
611
+ const explicitProfile = commandLineConfigOverrides.identityProfile?.trim();
612
+ if (explicitProfile) {
613
+ displayError(`identity profile "${explicitProfile}" not found: no config file in ` +
614
+ `${GSLOTH_DIR}/${GSLOTH_SETTINGS_DIR}/${explicitProfile}/ ` +
615
+ `(checked ${PROJECT_CONFIG_FORMATS.join(', ')})`);
616
+ exit(1);
617
+ // Unreachable past exit(1) in production. In specs exit() is mocked to a no-op, so this throw
618
+ // is LOAD-BEARING: without it execution would fall through into loadGlobalRawConfig() below
619
+ // and the test would observe the global silently loaded — masking the very regression this
620
+ // guard fixes. Matches the loader's existing post-exit sentinel-throw pattern.
621
+ throw new Error('Unexpected error occurred.');
622
+ }
623
+ const globalRawConfig = await loadGlobalRawConfig();
624
+ if (globalRawConfig) {
625
+ if (globalRawConfig.llm &&
626
+ typeof globalRawConfig.llm === 'object' &&
627
+ 'type' in globalRawConfig.llm) {
628
+ // Route the global config through the same path the project JSON uses.
629
+ return await tryJsonConfig(globalRawConfig, commandLineConfigOverrides);
630
+ }
631
+ displayError('Global configuration found but it is not in valid format. Should at least define llm.type');
632
+ exit(1);
633
+ // Unreachable past exit(1) in production; keeps TS happy and prevents test exit.
634
+ throw new Error('Unexpected error occurred.');
635
+ }
636
+ }
637
+ // Try loading the JSON/JSONC config file first (GS2-69 — an explicit `-c foo.jsonc` takes
638
+ // this branch too instead of falling into the `configure()`-module importer).
639
+ if (isJsonConfigPath(jsonConfigPath) && existsSync(jsonConfigPath)) {
640
+ const jsonConfigName = jsonConfigPath.endsWith('.jsonc')
641
+ ? USER_PROJECT_CONFIG_JSONC
642
+ : USER_PROJECT_CONFIG_JSON;
643
+ try {
644
+ // Validate the project config layer against the Zod schema (single source of
645
+ // truth): pre-map deprecated names, warn on unknown top-level keys, and fail
646
+ // with a friendly, path-scoped message on a genuine type mismatch.
647
+ const projectJsonConfig = validateRawConfigLayer(parseJsonc(readFileSync(jsonConfigPath, 'utf8'), jsonConfigName), jsonConfigName);
648
+ // GS2-41 — compose profile inheritance (`extends`) WITHIN the profile-dir layer BEFORE the
649
+ // global base underlays it, so precedence stays CLI > profile(base+child) > global > defaults.
650
+ // No-op (returns the config unchanged) when there is no `extends`.
651
+ const composedProjectConfig = (await resolveConfigExtends(projectJsonConfig, commandLineConfigOverrides.identityProfile));
652
+ // Apply global config as the base layer (project config wins on conflicts).
653
+ const jsonConfig = (await applyGlobalConfigBase(composedProjectConfig));
654
+ // If the config has an LLM with a type, create the appropriate LLM instance
655
+ if (jsonConfig.llm && typeof jsonConfig.llm === 'object' && 'type' in jsonConfig.llm) {
656
+ return await tryJsonConfig(jsonConfig, commandLineConfigOverrides);
657
+ }
658
+ else {
659
+ error(`${jsonConfigPath} is not in valid format. Should at least define llm.type`);
660
+ exit(1);
661
+ // noinspection ExceptionCaughtLocallyJS
662
+ // This throw is unreachable due to exit(1) above, but satisfies TS type analysis and prevents tests from exiting
663
+ // noinspection ExceptionCaughtLocallyJS
664
+ throw new Error('Unexpected error occurred.');
665
+ }
666
+ }
667
+ catch (e) {
668
+ // CFG-35 — the format fall-through must not swallow a resolvable-config-but-no-API-key
669
+ // failure. This catch exists to move on to the next config FORMAT when the JSON layer could
670
+ // not be read; a config that read fine and named a provider we have no key for is not a
671
+ // read failure, and falling through would end in the terminal "No configuration file found"
672
+ // exit — the opposite of catchable, and a misleading message besides.
673
+ if (isMissingProviderKeyError(e)) {
674
+ throw e;
675
+ }
676
+ displayDebug(e instanceof Error ? e : String(e));
677
+ displayError(`Failed to read config from ${jsonConfigName}, will try other formats.`);
678
+ // Continue to try other formats
679
+ return await tryModuleConfig('js', commandLineConfigOverrides, baseDir);
680
+ }
681
+ }
682
+ else {
683
+ // JSON config not found, try JS
684
+ return tryModuleConfig('js', commandLineConfigOverrides, baseDir);
685
+ }
686
+ }
687
+ /**
688
+ * Module-format fall-through order (lowest precedence among project formats; JSON is tried
689
+ * first by {@link initConfig}). `.ts` (B2b) is last, loaded via jiti by `importExternalFile`.
690
+ */
691
+ const MODULE_CONFIG_FORMATS = ['js', 'mjs', 'ts'];
692
+ const MODULE_CONFIG_FILENAME = {
693
+ js: USER_PROJECT_CONFIG_JS,
694
+ mjs: USER_PROJECT_CONFIG_MJS,
695
+ ts: USER_PROJECT_CONFIG_TS,
696
+ };
697
+ const MODULE_CONFIG_EXT = {
698
+ js: '.js',
699
+ mjs: '.mjs',
700
+ ts: '.ts',
701
+ };
702
+ /**
703
+ * Try loading a `configure()`-style module config (JS → MJS → TS), preserving the format
704
+ * fall-through: a missing/failed format falls through to the next in {@link MODULE_CONFIG_FORMATS};
705
+ * exhausting the chain is the terminal "no usable config" error. Collapses the formerly-duplicated
706
+ * `tryJsConfig`/`tryMjsConfig` helpers into one format-parameterized loader.
707
+ *
708
+ * NOTE: the terminal "No configuration file found" message intentionally advertises only
709
+ * json/js/mjs (the historical, asserted wording) — `.ts` is a quiet lowest-precedence fallback,
710
+ * and `.jsonc` (GS2-69) is a quiet spelling variant of the advertised `.json`.
711
+ */
712
+ async function tryModuleConfig(format, commandLineConfigOverrides, baseDir) {
713
+ const filename = MODULE_CONFIG_FILENAME[format];
714
+ const ext = MODULE_CONFIG_EXT[format];
715
+ const nextFormat = MODULE_CONFIG_FORMATS[MODULE_CONFIG_FORMATS.indexOf(format) + 1];
716
+ const configPath = commandLineConfigOverrides.customConfigPath ??
717
+ resolveConfigPath(baseDir, filename, commandLineConfigOverrides.identityProfile);
718
+ if (configPath.endsWith(ext) && existsSync(configPath)) {
719
+ try {
720
+ const i = await importExternalFile(configPath);
721
+ const customConfig = validateRawConfigLayer((await i.configure()), filename);
722
+ // GS2-41 — compose profile inheritance (`extends`) before the global base underlays it
723
+ // (parity with the JSON branch); no-op when there is no `extends`.
724
+ const composedConfig = await resolveConfigExtends(customConfig, commandLineConfigOverrides.identityProfile);
725
+ const mergedWithGlobal = await applyGlobalConfigBase(composedConfig);
726
+ return await mergeConfig(mergedWithGlobal, commandLineConfigOverrides);
727
+ }
728
+ catch (e) {
729
+ displayDebug(e instanceof Error ? e : String(e));
730
+ if (nextFormat) {
731
+ displayError(`Failed to read config from ${filename}, will try other formats.`);
732
+ // Continue to try other formats
733
+ return await tryModuleConfig(nextFormat, commandLineConfigOverrides, baseDir);
734
+ }
735
+ displayError(`Failed to read config from ${filename}.`);
736
+ displayError(`No valid configuration found. Please create a valid configuration file.`);
737
+ exit(1);
738
+ }
739
+ }
740
+ else if (nextFormat) {
741
+ // This format not found, try the next one
742
+ return await tryModuleConfig(nextFormat, commandLineConfigOverrides, baseDir);
743
+ }
744
+ else {
745
+ // No config files found
746
+ displayError('No configuration file found. Please create one of: ' +
747
+ `${USER_PROJECT_CONFIG_JSON}, ${USER_PROJECT_CONFIG_JS}, or ${USER_PROJECT_CONFIG_MJS} ` +
748
+ 'in your project directory.');
749
+ exit(1);
750
+ }
751
+ // This throw is unreachable due to exit(1) above, but satisfies TS type analysis and prevents tests from exiting
752
+ throw new Error('Unexpected error occurred.');
753
+ }
754
+ /**
755
+ * Process JSON LLM config by creating the appropriate LLM instance
756
+ * @param jsonConfig - The parsed JSON config
757
+ * @param commandLineConfigOverrides - command line config overrides
758
+ * @returns Promise<GthConfig>
759
+ */
760
+ export async function tryJsonConfig(jsonConfig, commandLineConfigOverrides) {
761
+ try {
762
+ if (jsonConfig.llm && typeof jsonConfig.llm === 'object') {
763
+ // Get the type of LLM (e.g. 'vertexai', 'anthropic') - this should exist
764
+ const llmType = jsonConfig.llm.type;
765
+ if (!llmType) {
766
+ displayError('LLM type not specified in config.');
767
+ exit(1);
768
+ }
769
+ // Get the configuration for the specific LLM type
770
+ const llmConfig = jsonConfig.llm;
771
+ if (commandLineConfigOverrides.verbose) {
772
+ // Necessary to avoid https://github.com/langchain-ai/langchainjs/issues/8705
773
+ llmConfig.verbose = commandLineConfigOverrides.verbose;
774
+ }
775
+ if (commandLineConfigOverrides.model) {
776
+ // BATCH-1 fix — see CommandLineConfigOverrides.model: retarget the raw LLM *spec* before
777
+ // the provider builds an instance from it, rather than cloning/mutating an already-built
778
+ // instance. `gth batch --models` is the only current caller.
779
+ llmConfig.model = commandLineConfigOverrides.model;
780
+ }
781
+ // Import the appropriate config module
782
+ const configModule = await import(`#src/providers/${llmType}.js`);
783
+ if (configModule.processJsonConfig) {
784
+ const llm = (await configModule.processJsonConfig(llmConfig));
785
+ const mergedConfig = mergeRawConfig(jsonConfig, llm, commandLineConfigOverrides);
786
+ if (configModule.postProcessJsonConfig) {
787
+ return await configModule.postProcessJsonConfig(mergedConfig);
788
+ }
789
+ else {
790
+ return await mergedConfig;
791
+ }
792
+ }
793
+ else {
794
+ displayWarning(`Config module for ${llmType} does not have processJsonConfig function.`);
795
+ exit(1);
796
+ }
797
+ }
798
+ else {
799
+ displayError('No LLM configuration found in config.');
800
+ exit(1);
801
+ }
802
+ }
803
+ catch (e) {
804
+ if (e instanceof Error && e.message.includes('Cannot find module')) {
805
+ displayError(`LLM type '${jsonConfig.llm.type}' not supported.`);
806
+ exit(1);
807
+ }
808
+ else {
809
+ const message = `Error processing LLM config: ${e instanceof Error ? e.message : String(e)}`;
810
+ // CFG-35 — a provider that could not be built because NO API key is resolvable for it is a
811
+ // CATCHABLE error, not a process exit. Several provider SDKs validate the key in their
812
+ // constructor (groq, anthropic, xai, deepseek, openrouter) and two of our factories check it
813
+ // themselves (openrouter, huggingface); every one of those throws landed here and killed the
814
+ // process, so a multi-identity `gth eval` run — the case that is DESIGNED for partial
815
+ // secrets — lost every other identity's result and wrote no artifacts at all.
816
+ //
817
+ // Whether this is a missing key is decided from the environment, never from the SDK's
818
+ // wording (see findMissingProviderKey). Imported dynamically because it is an error-path
819
+ // question and a static import of the provider layer would put a cycle through the config
820
+ // barrel. The message is exactly the string this branch has always printed, so a caller that
821
+ // reports `error.message` produces the same output; the provider and variable names ride as
822
+ // FIELDS, which is what a report consumer needs to tell a missing key from an outage.
823
+ const { findMissingProviderKey } = await import('#src/providers/modelDiscovery.js');
824
+ const missingKey = findMissingProviderKey(jsonConfig.llm?.type, jsonConfig.llm);
825
+ if (missingKey) {
826
+ throw new MissingProviderKeyError(message, missingKey, { cause: e });
827
+ }
828
+ displayError(message);
829
+ exit(1);
830
+ }
831
+ }
832
+ // This throw is unreachable due to exit(1) above, but satisfies TS type analysis and prevents tests from exiting
833
+ throw new Error('Unexpected error occurred.');
834
+ }
835
+ /**
836
+ * Config array fields whose values ADD UP across merge layers (global → project): the
837
+ * merged result is both layers concatenated (target/lower-precedence first) and de-duplicated
838
+ * by value, instead of the higher-precedence layer replacing the lower. Keyed by dotted path
839
+ * from the config root.
840
+ *
841
+ * CONSERVATIVE BY DESIGN — only genuinely-cumulative lists are additive. Everything else keeps
842
+ * REPLACE semantics, because those express "this is THE set" and silently unioning them across
843
+ * global + project would surprise users.
844
+ *
845
+ * | array field | policy | rationale |
846
+ * | ------------------------------- | -------- | --------------------------------------------- |
847
+ * | `allowDirs` | ADDITIVE | extra sandbox roots accumulate across layers |
848
+ * | `aiignore.patterns` | ADDITIVE | ignore patterns accumulate across layers |
849
+ * | `approvals.deny`/`escalate` | ADDITIVE | see {@link isAdditiveArrayField} |
850
+ * | `approvals.allow` | replace | a PERMISSIVE list; see the same doc |
851
+ * | `allowedTools` | replace | the explicit allow-list IS the set |
852
+ * | `builtInTools` | replace | the explicit tool selection IS the set |
853
+ * | `tools` | replace | live tool instances; union would be surprising|
854
+ * | `middleware` | replace | ordered pipeline; union would reorder/duplicate|
855
+ * | `binaryFormats` | replace | the declared format policy IS the set |
856
+ * | (every other array) | replace | default; preserves historical behaviour |
857
+ *
858
+ * NOTE: these keys only live at the config ROOT, so they are triggered only by the `deepMerge`
859
+ * calls that START at the config root (path === ''). There are TWO such sites: the
860
+ * `applyGlobalConfigBase(global, project)` merge, and — as of GS2-41 — the root-level `deepMerge`
861
+ * inside {@link resolveConfigExtends} that composes an `extends` base with its child profile. The
862
+ * per-command `deepMerge` calls start at command scope and never reach these paths.
863
+ *
864
+ * NAMESPACE CAVEAT: these keys are config-ROOT-relative, but the per-command
865
+ * `deepMerge(DEFAULT_CONFIG.commands.X, …)` calls also start at `path === ''`. No command
866
+ * default carries `allowDirs`/`aiignore`, so there is no collision today — but do NOT add a
867
+ * key to THIS SET that could also appear as a per-command field, or it would silently become
868
+ * additive inside command merges too. The approvals RESTRICTIVE lists are the deliberate exception
869
+ * and are therefore matched by path SHAPE ({@link isAdditiveArrayField}) rather than listed here:
870
+ * for them, additive at every scope is the rule and not an accident.
871
+ */
872
+ const ADDITIVE_ARRAY_FIELDS = new Set(['allowDirs', 'aiignore.patterns']);
873
+ /**
874
+ * §9.1/§11.1f — the approvals **restrictive** lists, wherever they appear. `deny` and `escalate`
875
+ * are PROHIBITIONS, and a prohibition another config layer can quietly delete is not a hardline:
876
+ * §3's "an appended entry can never perturb an existing outcome" has to hold across layers or it
877
+ * holds nowhere. So they add up rather than replace, and no layer can narrow another's.
878
+ *
879
+ * Matched by path SHAPE rather than by an exact root-relative path, because `approvals` is settable
880
+ * at the root AND per command — `commands.code.approvals.deny` has to add up across the global and
881
+ * project layers for exactly the same reason `approvals.deny` does, and listing only the root path
882
+ * would leave the identical silent loss one keystroke away.
883
+ *
884
+ * **`allow` is deliberately NOT here, and adding it would be a security regression.** It is the
885
+ * PERMISSIVE list, so it keeps the default REPLACE policy: a layer that states its own `allow`
886
+ * replaces the layer below, and one that says nothing inherits. §3.1's cost asymmetry is why the
887
+ * three lists do not share a policy — a missed allow entry escalates and a missed deny entry
888
+ * reaches the rater, but a too-broad allow entry runs, unrated and unprompted. Unioning the
889
+ * restrictive lists fails toward a prompt; unioning the permissive one fails toward an execution.
890
+ */
891
+ const APPROVAL_RESTRICTIVE_LIST_PATH = /(^|\.)approvals\.(deny|escalate)$/;
892
+ /** Whether the array at this dotted path accumulates across layers instead of being replaced. */
893
+ function isAdditiveArrayField(fieldPath) {
894
+ return ADDITIVE_ARRAY_FIELDS.has(fieldPath) || APPROVAL_RESTRICTIVE_LIST_PATH.test(fieldPath);
895
+ }
896
+ /** The `approvals` block itself, at the root or on any command. */
897
+ const APPROVALS_BLOCK_PATH = /(^|\.)approvals$/;
898
+ /**
899
+ * §9.1 — the `approvals` value is a union, and **the scalar rung is exactly sugar for
900
+ * `{ mode: <rung> }`**. Expanded at merge time so a layer that spells its rung the friendly way
901
+ * still merges field-wise: left as a string, a project config's `"approvals": "bypass"` simply
902
+ * overwrites the global's object and discards that layer's rule lists — the same silent loss
903
+ * {@link APPROVAL_RESTRICTIVE_LIST_PATH} exists to prevent, reached by a different route. It
904
+ * matters for the permissive list too, in the other direction: without the expansion a bare rung
905
+ * would silently drop the lower layer's `allow` rather than inheriting it.
906
+ */
907
+ function expandApprovalsScalar(value) {
908
+ return typeof value === 'string' ? { mode: value } : value;
909
+ }
910
+ /**
911
+ * Whether two layers' `approvals` values have to be merged FIELD-WISE rather than one replacing the
912
+ * other. True only when both layers set the key AND at least one of them is the object form — i.e.
913
+ * only when there is a field that replacement would lose.
914
+ *
915
+ * Deliberately narrow, because expanding the scalar changes the value's SHAPE: a config that is the
916
+ * only one to declare `approvals`, and two layers that both spell it as a bare rung, keep the exact
917
+ * value the user wrote, so `gth config print` and the `/config` panel do not churn for the
918
+ * overwhelmingly common cases.
919
+ */
920
+ function approvalsNeedFieldWiseMerge(sourceValue, targetValue) {
921
+ if (sourceValue === undefined || targetValue === undefined)
922
+ return false;
923
+ return typeof sourceValue === 'object' || typeof targetValue === 'object';
924
+ }
925
+ /**
926
+ * Deep merge two objects, with source overriding target properties.
927
+ * Objects are merged recursively. Arrays REPLACE by default; arrays at an
928
+ * {@link isAdditiveArrayField} path are concatenated (target-first) then de-duplicated by
929
+ * value. Every other non-plain-object value is replaced by the source value.
930
+ *
931
+ * The de-duplication is by VALUE identity (a `Set`), so it removes repeated primitives and leaves
932
+ * structurally-equal objects — an approvals rule entry written in two layers — both in place. That
933
+ * is correct rather than merely tolerable: a duplicate entry cannot change a most-restrictive-wins
934
+ * outcome, and a deep-equality pass could.
935
+ * @param target - The target object with default values (lower-precedence layer)
936
+ * @param source - The source object with user overrides (higher-precedence layer)
937
+ * @param path - Dotted path from the config root, used to look up the array merge policy.
938
+ */
939
+ function deepMerge(target, source, path = '') {
940
+ if (!source)
941
+ return target;
942
+ if (!target)
943
+ return source;
944
+ const result = { ...target };
945
+ for (const key in source) {
946
+ const fieldPath = path ? `${path}.${key}` : key;
947
+ // §9.1 — normalize the `approvals` scalar/object union to its object form when (and only when)
948
+ // a field would otherwise be lost, so the merge below is field-wise rather than a bare string
949
+ // clobbering a block that carries the user's rule lists.
950
+ const mergeApprovalsFieldWise = APPROVALS_BLOCK_PATH.test(fieldPath) && approvalsNeedFieldWiseMerge(source[key], target[key]);
951
+ const sourceValue = mergeApprovalsFieldWise ? expandApprovalsScalar(source[key]) : source[key];
952
+ const targetValue = mergeApprovalsFieldWise ? expandApprovalsScalar(target[key]) : target[key];
953
+ if (sourceValue &&
954
+ typeof sourceValue === 'object' &&
955
+ !Array.isArray(sourceValue) &&
956
+ targetValue &&
957
+ typeof targetValue === 'object' &&
958
+ !Array.isArray(targetValue)) {
959
+ // Recursively merge nested objects
960
+ result[key] = deepMerge(targetValue, sourceValue, fieldPath);
961
+ }
962
+ else if (Array.isArray(sourceValue) &&
963
+ Array.isArray(targetValue) &&
964
+ isAdditiveArrayField(fieldPath)) {
965
+ // Additive list: concat both layers (target/lower-precedence first), de-dupe by value.
966
+ result[key] = [...new Set([...targetValue, ...sourceValue])];
967
+ }
968
+ else if (sourceValue !== undefined) {
969
+ // Override with source value if it exists (arrays REPLACE by default)
970
+ result[key] = sourceValue;
971
+ }
972
+ }
973
+ return result;
974
+ }
975
+ /**
976
+ * Command-scoped fields whose effective value is picked by PRECEDENCE — one layer wins wholesale
977
+ * (a per-command value REPLACES rather than extends the top-level one; none is in
978
+ * {@link ADDITIVE_ARRAY_FIELDS}). {@link resolvePrecedencePickedField} bakes the correct value into
979
+ * every command inside {@link resolveConfig}, so `getEffectiveConfig`'s later per-command-vs-root
980
+ * ternary reads an already-resolved value.
981
+ *
982
+ * GS2-60 — historically these were resolved ONLY at agent-build time (`getEffectiveConfig`), across
983
+ * two layers (per-command value vs top-level value) AFTER `DEFAULT_CONFIG` had been merged in. That
984
+ * made an explicit top-level value lose to a per-command DEFAULT: once merged, a command's
985
+ * `filesystem` is always defined (from its `'read'`/`'all'` default), so the ternary always took it
986
+ * and never fell through to the user's explicit top-level `filesystem`. Resolving here against the
987
+ * RAW (pre-default) config — where "user set it" is still distinguishable from "it's a default" —
988
+ * is the single correct site. Only `filesystem` is actually affected today (the sole field with
989
+ * per-command defaults); the other three are baked in identically for principled future-proofing.
990
+ */
991
+ const PRECEDENCE_PICKED_COMMAND_FIELDS = [
992
+ 'filesystem',
993
+ 'builtInTools',
994
+ 'allowedTools',
995
+ 'binaryFormats',
996
+ ];
997
+ /**
998
+ * Resolve one {@link PRECEDENCE_PICKED_COMMAND_FIELDS} field for one command against the RAW
999
+ * (pre-{@link DEFAULT_CONFIG}) config, highest precedence first:
1000
+ *
1001
+ * 1. per-command explicit : `rawConfig.commands[command][field]`
1002
+ * 2. top-level explicit : `rawConfig[field]`
1003
+ * 3. per-command default : `DEFAULT_CONFIG.commands[command][field]`
1004
+ * 4. top-level default : `DEFAULT_CONFIG[field]`
1005
+ *
1006
+ * `!== undefined` at every layer so a falsy-but-EXPLICIT value (`'none'`, `[]`, `false`, `0`) is
1007
+ * honoured and never mistaken for a "missing" layer. Returns `undefined` only when no layer sets
1008
+ * the field (e.g. `allowedTools`/`binaryFormats` with neither a user value nor any default), in
1009
+ * which case the caller leaves the command key absent — matching prior behaviour.
1010
+ */
1011
+ function resolvePrecedencePickedField(rawConfig, command, field) {
1012
+ const rawCommands = rawConfig.commands;
1013
+ const perCommandExplicit = rawCommands?.[command]?.[field];
1014
+ if (perCommandExplicit !== undefined)
1015
+ return perCommandExplicit;
1016
+ const topLevelExplicit = rawConfig[field];
1017
+ if (topLevelExplicit !== undefined)
1018
+ return topLevelExplicit;
1019
+ const perCommandDefault = DEFAULT_CONFIG.commands[command]?.[field];
1020
+ if (perCommandDefault !== undefined)
1021
+ return perCommandDefault;
1022
+ return DEFAULT_CONFIG[field];
1023
+ }
1024
+ /**
1025
+ * Resolve a fully-merged {@link GthConfig} from a partial config + CLI overrides WITHOUT
1026
+ * any global side effects. It deep-merges defaults, applies CLI overrides, resolves the numeric
1027
+ * `consoleLevel` (warning + defaulting to INFO on an invalid value), and computes
1028
+ * `canInterruptInferenceWithEsc` and `useColour`. The process-global setters (`setUseColour` /
1029
+ * `setConsoleLevel`) are applied separately by {@link mergeConfig}, so this function can be
1030
+ * reasoned about and reused without touching global state.
1031
+ *
1032
+ * It WRITES nothing globally, but it does READ the environment: `canInterruptInferenceWithEsc`
1033
+ * consults stdin's TTY status, and (CFG-30) `useColour` consults `FORCE_COLOR`, `NO_COLOR` and
1034
+ * stdout's TTY status. So it is deterministic for a given environment rather than a pure function
1035
+ * of its arguments — a test that pins a resolved config should declare the terminal and the colour
1036
+ * environment it expects in its setup. The ladder itself is a pure helper
1037
+ * ({@link resolveUseColour}) so it can be tested rung by rung without process globals.
1038
+ */
1039
+ export function resolveConfig(partialConfig, commandLineConfigOverrides) {
1040
+ const config = partialConfig;
1041
+ // CFG-30 — capture whether the user set `useColour` AT ALL, while the config is still raw.
1042
+ // `DEFAULT_CONFIG.useColour` is merged in below, after which an explicit `true` and the default
1043
+ // `true` are the same value and rung 3 can no longer be told from rung 4. Read it here or lose it.
1044
+ const explicitUseColour = config?.useColour;
1045
+ // TUI-C37 — the same pre-merge capture for `useMouse`, and for the same reason: once
1046
+ // `DEFAULT_CONFIG.useMouse` is merged in, "the user asked for it" and "nobody said" are the same
1047
+ // value and the ladder's config rung can no longer be distinguished from its default rung.
1048
+ const explicitUseMouse = config?.useMouse;
1049
+ // Deep merge command configs while preserving defaults
1050
+ // Type complexity from DEFAULT_CONFIG.commands 'as const' requires any cast for deep merge result
1051
+ const mergedCommands = {
1052
+ pr: deepMerge(DEFAULT_CONFIG.commands.pr, config?.commands?.pr), // eslint-disable-line @typescript-eslint/no-explicit-any
1053
+ review: deepMerge(DEFAULT_CONFIG.commands.review, config?.commands?.review), // eslint-disable-line @typescript-eslint/no-explicit-any
1054
+ code: deepMerge(DEFAULT_CONFIG.commands.code, config?.commands?.code), // eslint-disable-line @typescript-eslint/no-explicit-any
1055
+ exec: deepMerge(DEFAULT_CONFIG.commands.exec, config?.commands?.exec), // eslint-disable-line @typescript-eslint/no-explicit-any
1056
+ ask: deepMerge(DEFAULT_CONFIG.commands.ask, config?.commands?.ask), // eslint-disable-line @typescript-eslint/no-explicit-any
1057
+ chat: deepMerge(DEFAULT_CONFIG.commands.chat, config?.commands?.chat), // eslint-disable-line @typescript-eslint/no-explicit-any
1058
+ api: deepMerge(DEFAULT_CONFIG.commands.api, config?.commands?.api), // eslint-disable-line @typescript-eslint/no-explicit-any
1059
+ };
1060
+ // GS2-60 — bake the correct 4-layer precedence for the precedence-picked fields
1061
+ // (filesystem/builtInTools/allowedTools/binaryFormats) into each command, resolved against the
1062
+ // RAW `config` (still pre-DEFAULT_CONFIG here). The per-command `deepMerge` above only ranks
1063
+ // per-command explicit vs per-command DEFAULT and cannot see the user's explicit top-level value
1064
+ // — so without this an explicit top-level `filesystem` was silently lost to a command's default.
1065
+ // A fresh `{ ...base, ...overrides }` per command is REQUIRED: `deepMerge` returns the live
1066
+ // `DEFAULT_CONFIG.commands[cmd]` reference verbatim when the user configured nothing for that
1067
+ // command, so in-place mutation would corrupt the shared default across every subsequent call.
1068
+ const commandsRecord = mergedCommands;
1069
+ for (const command of Object.keys(commandsRecord)) {
1070
+ const base = commandsRecord[command];
1071
+ const overrides = {};
1072
+ for (const field of PRECEDENCE_PICKED_COMMAND_FIELDS) {
1073
+ const resolved = resolvePrecedencePickedField(config, command, field);
1074
+ // Leave the key absent when no layer set it (preserves prior behaviour for e.g. a command
1075
+ // with no allowedTools anywhere), rather than writing an explicit `undefined`.
1076
+ if (resolved !== undefined) {
1077
+ overrides[field] = resolved;
1078
+ }
1079
+ }
1080
+ commandsRecord[command] = { ...base, ...overrides };
1081
+ }
1082
+ const mergedConfig = {
1083
+ ...DEFAULT_CONFIG,
1084
+ ...config,
1085
+ commands: mergedCommands,
1086
+ };
1087
+ if (commandLineConfigOverrides.identityProfile !== undefined) {
1088
+ displayInfo(`Activating profile: ${commandLineConfigOverrides.identityProfile}`);
1089
+ mergedConfig.identityProfile = commandLineConfigOverrides.identityProfile.trim();
1090
+ }
1091
+ if (commandLineConfigOverrides.verbose !== undefined) {
1092
+ mergedConfig.llm.verbose = commandLineConfigOverrides.verbose;
1093
+ }
1094
+ if (commandLineConfigOverrides.writeOutputToFile !== undefined) {
1095
+ mergedConfig.writeOutputToFile = commandLineConfigOverrides.writeOutputToFile;
1096
+ }
1097
+ // Resolve console logging level (value only; the global setter is applied in mergeConfig).
1098
+ if (mergedConfig.consoleLevel !== undefined) {
1099
+ const resolvedConsoleLevel = resolveConsoleLevel(mergedConfig.consoleLevel);
1100
+ if (resolvedConsoleLevel !== undefined) {
1101
+ mergedConfig.consoleLevel = resolvedConsoleLevel;
1102
+ }
1103
+ else {
1104
+ displayWarning(`Invalid consoleLevel "${String(mergedConfig.consoleLevel)}", using default ${StatusLevel.INFO}.`);
1105
+ mergedConfig.consoleLevel = StatusLevel.INFO;
1106
+ }
1107
+ }
1108
+ mergedConfig.canInterruptInferenceWithEsc = mergedConfig.canInterruptInferenceWithEsc && isTTY();
1109
+ // CFG-30 — resolve colour through the four-rung ladder (FORCE_COLOR > NO_COLOR > explicit config
1110
+ // > stdout auto-detect). `useColour` is top-level only (it is not one of the
1111
+ // PRECEDENCE_PICKED_COMMAND_FIELDS), so there is no per-command variant to reconcile.
1112
+ mergedConfig.useColour = resolveUseColour({
1113
+ forceColor: env.FORCE_COLOR,
1114
+ noColor: env.NO_COLOR,
1115
+ explicitUseColour,
1116
+ stdoutIsTTY: isStdoutTTY(),
1117
+ });
1118
+ // TUI-C37 — resolve mouse through its own ladder (GTH_NO_MOUSE > explicit config > TERM >
1119
+ // TTY auto-detect). Resolved here rather than in the TUI so the answer is one field on the
1120
+ // config, the way `useColour` is, instead of a decision each surface re-takes.
1121
+ mergedConfig.useMouse = resolveUseMouse({
1122
+ noMouse: env.GTH_NO_MOUSE,
1123
+ explicitUseMouse,
1124
+ term: env.TERM,
1125
+ stdoutIsTTY: isStdoutTTY(),
1126
+ stdinIsTTY: isTTY(),
1127
+ });
1128
+ return mergedConfig;
1129
+ }
1130
+ /**
1131
+ * Merge config with default config, then apply the resolved colour + console-level settings
1132
+ * to the process globals. Thin wrapper over the pure {@link resolveConfig}; kept `async` with
1133
+ * the same signature so every existing caller (`mergeRawConfig`, `tryJsConfig`, `tryMjsConfig`)
1134
+ * behaves identically — the two `set*` calls are the only global mutations in the merge path.
1135
+ */
1136
+ async function mergeConfig(partialConfig, commandLineConfigOverrides) {
1137
+ const mergedConfig = resolveConfig(partialConfig, commandLineConfigOverrides);
1138
+ // Set the useColour value in systemUtils.
1139
+ setUseColour(mergedConfig.useColour);
1140
+ // Set console logging level.
1141
+ if (mergedConfig.consoleLevel !== undefined) {
1142
+ setConsoleLevel(mergedConfig.consoleLevel);
1143
+ }
1144
+ return mergedConfig;
1145
+ }
1146
+ const CONSOLE_LEVELS_BY_NAME = {
1147
+ debug: StatusLevel.DEBUG,
1148
+ info: StatusLevel.INFO,
1149
+ display: StatusLevel.DISPLAY,
1150
+ success: StatusLevel.SUCCESS,
1151
+ warning: StatusLevel.WARNING,
1152
+ error: StatusLevel.ERROR,
1153
+ stream: StatusLevel.STREAM,
1154
+ };
1155
+ function resolveConsoleLevel(level) {
1156
+ if (typeof level === 'number') {
1157
+ return StatusLevel[level] !== undefined ? level : undefined;
1158
+ }
1159
+ if (typeof level === 'string') {
1160
+ const normalized = level.trim().toLowerCase();
1161
+ if (normalized in CONSOLE_LEVELS_BY_NAME) {
1162
+ return CONSOLE_LEVELS_BY_NAME[normalized];
1163
+ }
1164
+ const enumValue = StatusLevel[level];
1165
+ if (typeof enumValue === 'number') {
1166
+ return enumValue;
1167
+ }
1168
+ }
1169
+ return undefined;
1170
+ }
1171
+ /**
1172
+ * Read a raw config object from an on-disk path WITHOUT validating, building an LLM, or
1173
+ * merging defaults. JSON/JSONC files are parsed leniently ({@link parseJsonc}); module
1174
+ * formats (`.js`/`.mjs`/`.ts`) are imported and their `configure()` invoked. Used by the
1175
+ * read-side {@link validateConfig}; may throw on a parse/module error (surfaced by the caller).
1176
+ */
1177
+ async function readRawConfigAtPath(path) {
1178
+ if (path.endsWith('.json') || path.endsWith('.jsonc')) {
1179
+ return parseJsonc(readFileSync(path, 'utf8'), path);
1180
+ }
1181
+ const imported = await importExternalFile(path);
1182
+ return (await imported.configure());
1183
+ }
1184
+ /**
1185
+ * Global config read for the read-side {@link validateConfig}: mirrors
1186
+ * {@link loadGlobalRawConfig}'s lookup order (JSON → JSONC → JS → MJS) but does NOT validate or
1187
+ * `exit` — it just returns the raw object + a source label so the validator owns the verdict.
1188
+ *
1189
+ * Ignore-on-error, exactly like {@link loadGlobalRawConfig}: a parse/module failure of the
1190
+ * global file is treated as an ABSENT global (returns `undefined`), NOT a hard error — a real
1191
+ * run does the same, so the diagnostic must too, else a clean project + an unparseable global
1192
+ * would fail `gth config validate` while the run keeps going (the inverse of the GS2-29 bug).
1193
+ *
1194
+ * The failure is BOTH debug-logged AND surfaced as a user-facing `displayWarning` with the same
1195
+ * message `loadGlobalRawConfig` emits (`Failed to read global config from <path>, ignoring it.`).
1196
+ * A run warns the user while ignoring the broken global's VALUE; matching that message is what
1197
+ * keeps `gth config validate` a faithful mirror of the run rather than staying silent about a
1198
+ * problem the run flags.
1199
+ */
1200
+ async function loadGlobalRawConfigUnvalidated() {
1201
+ for (const filename of JSON_CONFIG_FILENAMES) {
1202
+ const jsonPath = getGlobalGslothConfigReadPath(filename);
1203
+ if (existsSync(jsonPath)) {
1204
+ const label = `${filename} (global)`;
1205
+ try {
1206
+ return {
1207
+ raw: parseJsonc(readFileSync(jsonPath, 'utf8'), label),
1208
+ label,
1209
+ };
1210
+ }
1211
+ catch (e) {
1212
+ displayDebug(e instanceof Error ? e : String(e));
1213
+ displayWarning(`Failed to read global config from ${jsonPath}, ignoring it.`);
1214
+ return undefined;
1215
+ }
1216
+ }
1217
+ }
1218
+ for (const filename of [USER_PROJECT_CONFIG_JS, USER_PROJECT_CONFIG_MJS]) {
1219
+ const modulePath = getGlobalGslothConfigReadPath(filename);
1220
+ if (existsSync(modulePath)) {
1221
+ try {
1222
+ const imported = await importExternalFile(modulePath);
1223
+ return {
1224
+ raw: (await imported.configure()),
1225
+ label: `${filename} (global)`,
1226
+ };
1227
+ }
1228
+ catch (e) {
1229
+ displayDebug(e instanceof Error ? e : String(e));
1230
+ displayWarning(`Failed to read global config from ${modulePath}, ignoring it.`);
1231
+ return undefined;
1232
+ }
1233
+ }
1234
+ }
1235
+ return undefined;
1236
+ }
1237
+ /**
1238
+ * Locate and validate the effective raw config against the schema WITHOUT building the LLM
1239
+ * or merging defaults (the read-side of GS2-1). Honours `--config`, up-tree discovery, and the
1240
+ * identity profile via {@link findProjectConfigPath}.
1241
+ *
1242
+ * GS2-29 — validates the SAME layer set a real run does: the discovered PROJECT layer (if any)
1243
+ * AND the GLOBAL layer (if any), mirroring `initConfig`'s `validateRawConfigLayer(project)` +
1244
+ * `applyGlobalConfigBase` → `loadGlobalRawConfig(global)`. Each present layer is validated
1245
+ * independently ({@link validateRawGthConfig}) and its outcome recorded in {@link
1246
+ * ConfigValidationReport.layers}, so a removed shape in EITHER file is reported (labelled with
1247
+ * its source) rather than under-reported.
1248
+ *
1249
+ * A PROJECT-layer JSONC/module parse failure is thrown to the caller (surfaced as a clear
1250
+ * "invalid config" error + non-zero exit). A GLOBAL-layer parse failure is treated as an absent
1251
+ * global (no layer added) but is surfaced with a `displayWarning` — exactly as a run does (it
1252
+ * warns the user while ignoring the broken global's value) — see {@link
1253
+ * loadGlobalRawConfigUnvalidated}.
1254
+ *
1255
+ * GS2-73 — for the PROJECT layer it also walks the GS2-41 profile `extends` chain (via the SAME
1256
+ * {@link composeExtends}/{@link resolveExtendsChain} the run path uses), so a cycle or a missing
1257
+ * base — which fail a real run — is reported here as a not-ok layer instead of passing OK and only
1258
+ * failing at run time. The GLOBAL layer is NOT walked, mirroring the run (`resolveConfigExtends`
1259
+ * runs on the project/profile layer only).
1260
+ */
1261
+ export async function validateConfig(commandLineConfigOverrides) {
1262
+ const layers = [];
1263
+ // Project layer first (run order): initConfig validates the discovered project config before
1264
+ // applying the global base. A parse failure here propagates (surfaced by the caller).
1265
+ const discovered = findProjectConfigPath(commandLineConfigOverrides);
1266
+ if (discovered) {
1267
+ const raw = await readRawConfigAtPath(discovered.path);
1268
+ const layer = {
1269
+ sourceLabel: discovered.path,
1270
+ ...validateRawGthConfig(raw, RAW_CONFIG_VALIDATION_OPTIONS),
1271
+ };
1272
+ // GS2-73 — mirror the run's `extends` resolution. GS2-41's `resolveConfigExtends` walks the
1273
+ // profile inheritance chain and hard-fails a real run on a cycle or a missing base; the read
1274
+ // side must surface the SAME failures, else a profile whose `extends` names a missing base (or
1275
+ // forms a cycle) reports OK here yet dies at run time — the exact GS2-29 "validate mirrors the
1276
+ // layer set a run loads" divergence. Reuse the SAME traversal (via {@link composeExtends}) —
1277
+ // no forked walk — and record its typed failure as a not-ok layer instead of exiting. Gated on
1278
+ // `layer.ok` and on a string `extends`, mirroring run order (a run validates the raw shape and
1279
+ // only THEN resolves `extends`) and skipping the read + walk for the common no-`extends` config.
1280
+ if (layer.ok && typeof raw.extends === 'string') {
1281
+ try {
1282
+ await composeExtends(raw, commandLineConfigOverrides.identityProfile);
1283
+ }
1284
+ catch (e) {
1285
+ if (e instanceof ConfigExtendsError) {
1286
+ layer.ok = false;
1287
+ layer.errorMessage = e.message;
1288
+ }
1289
+ else {
1290
+ throw e;
1291
+ }
1292
+ }
1293
+ }
1294
+ layers.push(layer);
1295
+ }
1296
+ // Global layer next: a run ALWAYS applies it (applyGlobalConfigBase in the project path,
1297
+ // loadGlobalRawConfig in the no-project path), so the diagnostic must validate it too — this is
1298
+ // the layer the previous single-layer validateConfig skipped whenever a project config existed.
1299
+ const globalRaw = await loadGlobalRawConfigUnvalidated();
1300
+ if (globalRaw) {
1301
+ layers.push({
1302
+ sourceLabel: globalRaw.label,
1303
+ ...validateRawGthConfig(globalRaw.raw, RAW_CONFIG_VALIDATION_OPTIONS),
1304
+ });
1305
+ }
1306
+ // Vacuous-truth guard: `every` is true on an empty array, so gate `ok` on a config existing.
1307
+ return { found: layers.length > 0, ok: layers.length > 0 && layers.every((l) => l.ok), layers };
1308
+ }
1309
+ /**
1310
+ * Merge raw with default config
1311
+ */
1312
+ async function mergeRawConfig(config, llm, commandLineConfigOverrides) {
1313
+ const modelDisplayName = config.llm?.model;
1314
+ // GS2-53 — stash the raw provider `type` (openrouter/deepseek/xai/…) BEFORE the built `llm`
1315
+ // replaces the raw `llm` spec below: it is the true configured provider and the only place the
1316
+ // OpenAI-compatible shims' real identity survives (their `_llmType()` reports `openai`).
1317
+ // `resolveModelIdentity` prefers it over `_llmType()`. INTERNAL field (not in the config schema).
1318
+ const modelProviderType = config.llm?.type;
1319
+ return await mergeConfig({ ...config, llm, modelDisplayName, modelProviderType }, commandLineConfigOverrides);
1320
+ }
1321
+ //# sourceMappingURL=loader.js.map