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