@gaunt-sloth/core 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (158) hide show
  1. package/.gsloth.code.md +10 -0
  2. package/README.md +6 -6
  3. package/dist/config/defaults.d.ts +85 -0
  4. package/dist/config/defaults.js +103 -0
  5. package/dist/config/defaults.js.map +1 -0
  6. package/dist/config/jsonc.d.ts +12 -0
  7. package/dist/config/jsonc.js +41 -0
  8. package/dist/config/jsonc.js.map +1 -0
  9. package/dist/config/loader.d.ts +169 -0
  10. package/dist/config/loader.js +872 -0
  11. package/dist/config/loader.js.map +1 -0
  12. package/dist/config/schema.d.ts +673 -0
  13. package/dist/config/schema.js +524 -0
  14. package/dist/config/schema.js.map +1 -0
  15. package/dist/config/shell-policy.d.ts +331 -0
  16. package/dist/config/shell-policy.js +238 -0
  17. package/dist/config/shell-policy.js.map +1 -0
  18. package/dist/config/types.d.ts +596 -0
  19. package/dist/config/types.js +13 -0
  20. package/dist/config/types.js.map +1 -0
  21. package/dist/config.d.ts +18 -647
  22. package/dist/config.js +15 -516
  23. package/dist/config.js.map +1 -1
  24. package/dist/constants.d.ts +42 -0
  25. package/dist/constants.js +42 -0
  26. package/dist/constants.js.map +1 -1
  27. package/dist/core/GthAbstractAgent.d.ts +85 -2
  28. package/dist/core/GthAbstractAgent.js +398 -28
  29. package/dist/core/GthAbstractAgent.js.map +1 -1
  30. package/dist/core/GthAgentRunner.d.ts +158 -1
  31. package/dist/core/GthAgentRunner.js +367 -4
  32. package/dist/core/GthAgentRunner.js.map +1 -1
  33. package/dist/core/GthLangChainAgent.js +193 -16
  34. package/dist/core/GthLangChainAgent.js.map +1 -1
  35. package/dist/core/debugCapture.d.ts +59 -0
  36. package/dist/core/debugCapture.js +101 -0
  37. package/dist/core/debugCapture.js.map +1 -0
  38. package/dist/core/gthLeanAgentFactory.d.ts +9 -0
  39. package/dist/core/gthLeanAgentFactory.js +10 -0
  40. package/dist/core/gthLeanAgentFactory.js.map +1 -0
  41. package/dist/core/plainToolIndication.d.ts +15 -0
  42. package/dist/core/plainToolIndication.js +148 -0
  43. package/dist/core/plainToolIndication.js.map +1 -0
  44. package/dist/core/runStats.d.ts +41 -0
  45. package/dist/core/runStats.js +73 -0
  46. package/dist/core/runStats.js.map +1 -0
  47. package/dist/core/shell/ShellCommandFailedError.d.ts +54 -0
  48. package/dist/core/shell/ShellCommandFailedError.js +68 -0
  49. package/dist/core/shell/ShellCommandFailedError.js.map +1 -0
  50. package/dist/core/shell/allowlist.d.ts +75 -0
  51. package/dist/core/shell/allowlist.js +187 -0
  52. package/dist/core/shell/allowlist.js.map +1 -0
  53. package/dist/core/shell/arity.d.ts +75 -0
  54. package/dist/core/shell/arity.js +313 -0
  55. package/dist/core/shell/arity.js.map +1 -0
  56. package/dist/core/shell/judge.d.ts +161 -0
  57. package/dist/core/shell/judge.js +261 -0
  58. package/dist/core/shell/judge.js.map +1 -0
  59. package/dist/core/shell/normalize.d.ts +27 -0
  60. package/dist/core/shell/normalize.js +53 -0
  61. package/dist/core/shell/normalize.js.map +1 -0
  62. package/dist/core/toolDisplay.d.ts +101 -0
  63. package/dist/core/toolDisplay.js +374 -0
  64. package/dist/core/toolDisplay.js.map +1 -0
  65. package/dist/core/toolOutputChannel.d.ts +68 -0
  66. package/dist/core/toolOutputChannel.js +112 -0
  67. package/dist/core/toolOutputChannel.js.map +1 -0
  68. package/dist/core/types.d.ts +173 -0
  69. package/dist/core/types.js.map +1 -1
  70. package/dist/history/historyFormat.d.ts +28 -0
  71. package/dist/history/historyFormat.js +127 -0
  72. package/dist/history/historyFormat.js.map +1 -0
  73. package/dist/history/historyStore.d.ts +198 -0
  74. package/dist/history/historyStore.js +482 -0
  75. package/dist/history/historyStore.js.map +1 -0
  76. package/dist/history/recordSession.d.ts +37 -0
  77. package/dist/history/recordSession.js +56 -0
  78. package/dist/history/recordSession.js.map +1 -0
  79. package/dist/index.d.ts +4 -0
  80. package/dist/index.js +4 -0
  81. package/dist/index.js.map +1 -1
  82. package/dist/providers/anthropic.d.ts +1 -1
  83. package/dist/providers/anthropic.js +5 -10
  84. package/dist/providers/anthropic.js.map +1 -1
  85. package/dist/providers/deepseek.d.ts +1 -1
  86. package/dist/providers/deepseek.js +5 -10
  87. package/dist/providers/deepseek.js.map +1 -1
  88. package/dist/providers/geminiSchemaSanitizer.d.ts +52 -0
  89. package/dist/providers/geminiSchemaSanitizer.js +201 -0
  90. package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
  91. package/dist/providers/google-genai.d.ts +1 -1
  92. package/dist/providers/google-genai.js +9 -11
  93. package/dist/providers/google-genai.js.map +1 -1
  94. package/dist/providers/groq.d.ts +1 -1
  95. package/dist/providers/groq.js +5 -10
  96. package/dist/providers/groq.js.map +1 -1
  97. package/dist/providers/huggingface.d.ts +25 -0
  98. package/dist/providers/huggingface.js +69 -0
  99. package/dist/providers/huggingface.js.map +1 -0
  100. package/dist/providers/modelCatalog.d.ts +109 -0
  101. package/dist/providers/modelCatalog.js +245 -0
  102. package/dist/providers/modelCatalog.js.map +1 -0
  103. package/dist/providers/modelDiscovery.d.ts +111 -3
  104. package/dist/providers/modelDiscovery.js +183 -27
  105. package/dist/providers/modelDiscovery.js.map +1 -1
  106. package/dist/providers/ollama.d.ts +19 -5
  107. package/dist/providers/ollama.js +60 -52
  108. package/dist/providers/ollama.js.map +1 -1
  109. package/dist/providers/openai.d.ts +1 -1
  110. package/dist/providers/openai.js +5 -10
  111. package/dist/providers/openai.js.map +1 -1
  112. package/dist/providers/openrouter.d.ts +1 -1
  113. package/dist/providers/openrouter.js +12 -12
  114. package/dist/providers/openrouter.js.map +1 -1
  115. package/dist/providers/vertexai.d.ts +1 -1
  116. package/dist/providers/vertexai.js +9 -11
  117. package/dist/providers/vertexai.js.map +1 -1
  118. package/dist/providers/xai.d.ts +1 -1
  119. package/dist/providers/xai.js +5 -10
  120. package/dist/providers/xai.js.map +1 -1
  121. package/dist/runtime/askStructured.d.ts +62 -0
  122. package/dist/runtime/askStructured.js +76 -0
  123. package/dist/runtime/askStructured.js.map +1 -0
  124. package/dist/runtime/conversation.d.ts +59 -0
  125. package/dist/runtime/conversation.js +137 -0
  126. package/dist/runtime/conversation.js.map +1 -0
  127. package/dist/runtime/singleShot.d.ts +24 -5
  128. package/dist/runtime/singleShot.js +56 -9
  129. package/dist/runtime/singleShot.js.map +1 -1
  130. package/dist/utils/aiignoreUtils.js.map +1 -1
  131. package/dist/utils/binaryOutputUtils.js.map +1 -1
  132. package/dist/utils/consoleUtils.d.ts +22 -0
  133. package/dist/utils/consoleUtils.js +45 -0
  134. package/dist/utils/consoleUtils.js.map +1 -1
  135. package/dist/utils/debugDump.d.ts +57 -0
  136. package/dist/utils/debugDump.js +236 -0
  137. package/dist/utils/debugDump.js.map +1 -0
  138. package/dist/utils/debugUtils.d.ts +13 -4
  139. package/dist/utils/debugUtils.js +36 -13
  140. package/dist/utils/debugUtils.js.map +1 -1
  141. package/dist/utils/fileUtils.d.ts +24 -2
  142. package/dist/utils/fileUtils.js +54 -12
  143. package/dist/utils/fileUtils.js.map +1 -1
  144. package/dist/utils/llmUtils.js.map +1 -1
  145. package/dist/utils/redactSecrets.d.ts +63 -0
  146. package/dist/utils/redactSecrets.js +238 -0
  147. package/dist/utils/redactSecrets.js.map +1 -0
  148. package/dist/utils/systemPromptNotes.d.ts +181 -0
  149. package/dist/utils/systemPromptNotes.js +298 -0
  150. package/dist/utils/systemPromptNotes.js.map +1 -0
  151. package/dist/utils/systemUtils.d.ts +32 -1
  152. package/dist/utils/systemUtils.js +50 -3
  153. package/dist/utils/systemUtils.js.map +1 -1
  154. package/dist/utils/toolMatching.d.ts +30 -0
  155. package/dist/utils/toolMatching.js +44 -0
  156. package/dist/utils/toolMatching.js.map +1 -0
  157. package/package.json +17 -8
  158. package/schema/gsloth-config.schema.json +2111 -0
@@ -0,0 +1,872 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * Configuration discovery + the layered load/merge pipeline (global + project layers,
4
+ * format fall-through JSON → JS → MJS, schema validation, deep-merge with defaults).
5
+ * Extracted from the former `config.ts` god-file; behaviour is unchanged.
6
+ */
7
+ import { GSLOTH_DIR, GSLOTH_SETTINGS_DIR, USER_PROJECT_CONFIG_JS, USER_PROJECT_CONFIG_JSON, USER_PROJECT_CONFIG_MJS, USER_PROJECT_CONFIG_TS, } from '#src/constants.js';
8
+ import { StatusLevel } from '#src/core/types.js';
9
+ import { displayDebug, displayError, displayInfo, displayWarning, setConsoleLevel, } from '#src/utils/consoleUtils.js';
10
+ import { findDeprecatedConfigIssues, findUnknownTopLevelKeys, formatConfigValidationError, formatDeprecatedConfigIssues, isRecordConfig, rawGthConfigSchema, validateRawGthConfig, } from '#src/config/schema.js';
11
+ import { parseJsonc } from '#src/config/jsonc.js';
12
+ 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 { existsSync, readFileSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, resolve } from 'node:path';
18
+ import { DEFAULT_CONFIG } from '#src/config/defaults.js';
19
+ /**
20
+ * Validate (and normalize) a freshly loaded raw config layer (global or project)
21
+ * against {@link rawGthConfigSchema}, the single source of truth for the on-disk
22
+ * config shape.
23
+ *
24
+ * Steps, in order:
25
+ * 1. Deprecated-shape reject (GS2-28): a removed pre-2.0 shape — a top-level command key
26
+ * or a deprecated `*Provider*` name (root + per-command), detected by
27
+ * {@link findDeprecatedConfigIssues} — is a HARD error naming the canonical replacement +
28
+ * migration path, then exit. 2.0 dropped back-compat coercion, so these fail rather than
29
+ * remap. Runs FIRST so a deprecated name never merely surfaces as an unknown-key warning.
30
+ * 2. Unknown top-level keys: warn (do NOT fail) so likely typos are surfaced while
31
+ * forward-compatible / extension keys still pass through untouched.
32
+ * 3. Schema parse: on a genuine type mismatch on a known field, emit a friendly,
33
+ * path-scoped error and exit (matching the loader's existing invalid-config
34
+ * behaviour). Validation is shape-only — the loose schema preserves unknown keys,
35
+ * so the original `raw` is returned unchanged on success.
36
+ *
37
+ * @param raw The freshly loaded config layer (read-only here).
38
+ * @param sourceLabel Human-readable source name for messages (e.g. the filename).
39
+ */
40
+ function validateRawConfigLayer(raw, sourceLabel) {
41
+ // Only an object config can carry deprecated/unknown keys; a null/array/primitive config skips
42
+ // the scans (they'd throw a raw TypeError) and falls to safeParse, which emits a clean
43
+ // "expected object" error + exit — never a coercion to {} (which would wrongly pass).
44
+ if (isRecordConfig(raw)) {
45
+ const deprecatedIssues = findDeprecatedConfigIssues(raw);
46
+ if (deprecatedIssues.length > 0) {
47
+ displayError(`Invalid configuration in ${sourceLabel}:\n${formatDeprecatedConfigIssues(deprecatedIssues)}`);
48
+ exit(1);
49
+ // Unreachable past exit(1) in production; keeps the mocked-exit test path from falling
50
+ // through into the schema parse below.
51
+ return raw;
52
+ }
53
+ const unknownKeys = findUnknownTopLevelKeys(raw);
54
+ if (unknownKeys.length > 0) {
55
+ displayWarning(`Unknown top-level config ${unknownKeys.length === 1 ? 'key' : 'keys'} in ${sourceLabel}: ` +
56
+ `${unknownKeys.join(', ')}. ${unknownKeys.length === 1 ? 'It is' : 'They are'} kept as-is ` +
57
+ 'but ignored by Gaunt Sloth; check for typos.');
58
+ }
59
+ }
60
+ const result = rawGthConfigSchema.safeParse(raw);
61
+ if (!result.success) {
62
+ displayError(`Invalid configuration in ${sourceLabel}:\n${formatConfigValidationError(result.error)}`);
63
+ exit(1);
64
+ }
65
+ return raw;
66
+ }
67
+ /**
68
+ * Project config file lookup order, highest precedence first. JSON wins, then the
69
+ * `configure()`-style module formats (JS → MJS → TS). Used to pick THE config within a dir.
70
+ */
71
+ const PROJECT_CONFIG_FORMATS = [
72
+ USER_PROJECT_CONFIG_JSON,
73
+ USER_PROJECT_CONFIG_JS,
74
+ USER_PROJECT_CONFIG_MJS,
75
+ USER_PROJECT_CONFIG_TS,
76
+ ];
77
+ /**
78
+ * Dir-aware version of {@link getGslothConfigReadPath} for ancestor dirs during the up-tree
79
+ * walk. Mirrors its `.gsloth/.gsloth-settings[/<profile>]/<filename>` resolution but against an
80
+ * explicit `dir` instead of the cwd, falling back to `<dir>/<filename>`. Implemented with
81
+ * `node:path`/`node:fs` directly (no `fileUtils` round-trip) so the cwd level can keep
82
+ * delegating to the original cwd-bound resolver.
83
+ */
84
+ function resolveProjectConfigPathInDir(dir, filename, identityProfileRaw) {
85
+ const identityProfile = identityProfileRaw?.trim();
86
+ const gslothDirPath = resolve(dir, GSLOTH_DIR);
87
+ if (existsSync(gslothDirPath)) {
88
+ const gslothSettingsPath = resolve(gslothDirPath, GSLOTH_SETTINGS_DIR);
89
+ const configPath = identityProfile
90
+ ? resolve(gslothSettingsPath, identityProfile, filename)
91
+ : resolve(gslothSettingsPath, filename);
92
+ if (existsSync(configPath)) {
93
+ return configPath;
94
+ }
95
+ }
96
+ return resolve(dir, filename);
97
+ }
98
+ /**
99
+ * Resolve where a config `filename` would live for the given base dir, composing with
100
+ * `identityProfile`. The cwd level delegates to the existing cwd-bound
101
+ * {@link getGslothConfigReadPath} (preserving its behaviour and test seams); ancestor dirs use
102
+ * {@link resolveProjectConfigPathInDir}.
103
+ */
104
+ function resolveConfigPath(baseDir, filename, identityProfile) {
105
+ return baseDir === getCurrentWorkDir()
106
+ ? getGslothConfigReadPath(filename, identityProfile)
107
+ : resolveProjectConfigPathInDir(baseDir, filename, identityProfile);
108
+ }
109
+ /**
110
+ * Yield each directory to search during config discovery, from cwd up to (and INCLUDING) the stop
111
+ * boundary — a dir containing `.git` (the git root), the user's home dir, or the filesystem root,
112
+ * whichever comes first (the dir at the boundary is itself searched, then ascent stops). Single-
113
+ * sources the up-tree boundary so {@link findProjectConfigPath} and
114
+ * {@link resolveIdentityProfileConfigPath} can never drift on where the walk starts or stops.
115
+ */
116
+ function* walkConfigSearchDirs() {
117
+ const home = homedir();
118
+ let dir = getCurrentWorkDir();
119
+ for (;;) {
120
+ yield dir;
121
+ const parent = dirname(dir);
122
+ if (existsSync(resolve(dir, '.git')) || dir === home || parent === dir) {
123
+ break;
124
+ }
125
+ dir = parent;
126
+ }
127
+ }
128
+ /**
129
+ * Find THE project config by walking up from cwd toward a stop boundary, returning the FIRST
130
+ * match (first-match-win: nearest dir, then format precedence within that dir — NOT a merged
131
+ * stack). Detection ({@link hasProjectConfig}/{@link hasAnyConfig}) and loading ({@link initConfig})
132
+ * both go through this, so they can never disagree.
133
+ *
134
+ * Stop boundary — the dir is SEARCHED, then ascent stops at: a dir containing `.git` (the git
135
+ * root), the user's home dir, or the filesystem root — whichever comes first. So a config IN the
136
+ * git root (or home) is found; a config ABOVE it is not.
137
+ *
138
+ * A `customConfigPath` override wins outright (no walking).
139
+ *
140
+ * NOTE (identity profile): with an `identityProfile` set, each dir's per-format resolver
141
+ * ({@link resolveConfigPath}) tries the profile path `.gsloth/.gsloth-settings/<profile>/<file>`
142
+ * but FALLS BACK to the plain `<dir>/<file>` when the profile file is absent. So a match here does
143
+ * NOT prove the named profile itself has a config — it may be a plain (non-profile) config. Use
144
+ * {@link resolveIdentityProfileConfigPath} when you need to know a profile specifically resolved.
145
+ *
146
+ * @returns the matched `{ dir, path }`, or `undefined` when no project config exists within the
147
+ * boundary.
148
+ */
149
+ export function findProjectConfigPath(commandLineConfigOverrides) {
150
+ if (commandLineConfigOverrides.customConfigPath) {
151
+ return existsSync(commandLineConfigOverrides.customConfigPath)
152
+ ? {
153
+ dir: dirname(commandLineConfigOverrides.customConfigPath),
154
+ path: commandLineConfigOverrides.customConfigPath,
155
+ }
156
+ : undefined;
157
+ }
158
+ // Walk up: search each dir, then stop at the boundary (git root / home / fs root).
159
+ for (const dir of walkConfigSearchDirs()) {
160
+ for (const filename of PROJECT_CONFIG_FORMATS) {
161
+ const candidate = resolveConfigPath(dir, filename, commandLineConfigOverrides.identityProfile);
162
+ if (existsSync(candidate)) {
163
+ return { dir, path: candidate };
164
+ }
165
+ }
166
+ }
167
+ return undefined;
168
+ }
169
+ /**
170
+ * STRICT existence check for an EXPLICITLY-named identity profile: does
171
+ * `.gsloth/.gsloth-settings/<identityProfile>/<config>` resolve to a real config file anywhere in
172
+ * the same up-tree search {@link findProjectConfigPath} walks? Returns the resolved profile config
173
+ * path (nearest dir, then format precedence) when the profile has its OWN config, `undefined`
174
+ * otherwise.
175
+ *
176
+ * Unlike {@link findProjectConfigPath}, it matches ONLY the profile-specific path — it NEVER falls
177
+ * through to a plain `<dir>/<config>` and NEVER falls back to the global config. That strictness is
178
+ * the whole point: it lets a caller distinguish "this named profile really exists" from "a bare
179
+ * config happens to be present / a global config exists," a distinction the loader's fall-through
180
+ * deliberately blurs.
181
+ *
182
+ * PURE PREDICATE — never throws, never calls `exit` (contrast the loader's interactive-CLI
183
+ * `exit(1)` safety net in {@link initConfig}). So batch/eval code (e.g. `gth eval --judge <profile>`
184
+ * and BATCH-12's identity matrix) can pre-check an explicitly-requested profile and raise its OWN
185
+ * catchable error / graceful exit code instead of dying on an uncatchable `process.exit`. A
186
+ * blank/whitespace-only name counts as "no profile" → `undefined`.
187
+ *
188
+ * @param identityProfile The explicitly-requested identity profile name.
189
+ * @returns The resolved profile config path, or `undefined` when the profile has no config.
190
+ */
191
+ export function resolveIdentityProfileConfigPath(identityProfile) {
192
+ const profile = identityProfile?.trim();
193
+ if (!profile) {
194
+ return undefined;
195
+ }
196
+ for (const dir of walkConfigSearchDirs()) {
197
+ const profileDir = resolve(dir, GSLOTH_DIR, GSLOTH_SETTINGS_DIR, profile);
198
+ for (const filename of PROJECT_CONFIG_FORMATS) {
199
+ const candidate = resolve(profileDir, filename);
200
+ if (existsSync(candidate)) {
201
+ return candidate;
202
+ }
203
+ }
204
+ }
205
+ return undefined;
206
+ }
207
+ /**
208
+ * Loads the global gsloth config (if present) from the global `~/.gsloth` folder.
209
+ *
210
+ * Precedence support: the returned raw config is intended to act as the BASE that the
211
+ * project config (and CLI overrides) merge on top of, so any value here is the lowest
212
+ * user-controlled layer (still above {@link DEFAULT_CONFIG}).
213
+ *
214
+ * Lookup order within the global folder, first match wins:
215
+ * `.gsloth.config.json` -> `.gsloth.config.js` -> `.gsloth.config.mjs`
216
+ *
217
+ * Absence of every variant is a no-op: returns `undefined` so behaviour is unchanged.
218
+ *
219
+ * NOTE: secrets (API keys) may live in this file; this function must never log its
220
+ * contents. Only non-sensitive diagnostics (the resolved path / parse failure) are emitted.
221
+ *
222
+ * @returns The raw global config object, or `undefined` when no global config exists.
223
+ */
224
+ export async function loadGlobalRawConfig() {
225
+ // JSON first (the must-have format).
226
+ const jsonPath = getGlobalGslothConfigReadPath(USER_PROJECT_CONFIG_JSON);
227
+ if (existsSync(jsonPath)) {
228
+ try {
229
+ const parsed = parseJsonc(readFileSync(jsonPath, 'utf8'), `${USER_PROJECT_CONFIG_JSON} (global)`);
230
+ return validateRawConfigLayer(parsed, `${USER_PROJECT_CONFIG_JSON} (global)`);
231
+ }
232
+ catch (e) {
233
+ displayDebug(e instanceof Error ? e : String(e));
234
+ displayWarning(`Failed to read global config from ${jsonPath}, ignoring it.`);
235
+ return undefined;
236
+ }
237
+ }
238
+ // Then JS / MJS variants (dynamic import of a `configure()` module).
239
+ for (const filename of [USER_PROJECT_CONFIG_JS, USER_PROJECT_CONFIG_MJS]) {
240
+ const modulePath = getGlobalGslothConfigReadPath(filename);
241
+ if (existsSync(modulePath)) {
242
+ try {
243
+ const imported = await importExternalFile(modulePath);
244
+ const configured = await imported.configure();
245
+ return validateRawConfigLayer(configured, `${filename} (global)`);
246
+ }
247
+ catch (e) {
248
+ displayDebug(e instanceof Error ? e : String(e));
249
+ displayWarning(`Failed to read global config from ${modulePath}, ignoring it.`);
250
+ return undefined;
251
+ }
252
+ }
253
+ }
254
+ return undefined;
255
+ }
256
+ /**
257
+ * Deep-merges a loaded global raw config UNDER the given project raw config, so the
258
+ * project config wins on conflicting keys. When no global config exists this is a no-op
259
+ * and the original project config is returned unchanged.
260
+ */
261
+ async function applyGlobalConfigBase(projectRawConfig) {
262
+ const globalRawConfig = await loadGlobalRawConfig();
263
+ if (!globalRawConfig) {
264
+ return projectRawConfig;
265
+ }
266
+ return deepMerge(globalRawConfig, projectRawConfig);
267
+ }
268
+ /**
269
+ * ORDERING INVARIANT (GS2-11): detection ({@link hasProjectConfig}/{@link hasAnyConfig}) MUST run
270
+ * before {@link initConfig} in a given process. Both resolve cwd-level candidates via
271
+ * `getGslothConfigReadPath`, which reads `getProjectDir()`; {@link initConfig} clears `projectDir`
272
+ * at the start of its run, so detection stays cwd-correct as long as it precedes initConfig (it
273
+ * does: startSession calls hasAnyConfig before any initConfig, and the ACP/agent path calls
274
+ * initConfig directly without detection). Calling detection AFTER an initConfig with a changed cwd
275
+ * in a long-lived process would read a stale projectDir (currently unreachable). If that call
276
+ * order is ever introduced, decouple discovery's cwd-branch from `getProjectDir()`.
277
+ */
278
+ /**
279
+ * Returns true when a project-level config file (json/js/mjs) exists for the given
280
+ * overrides. Honours `customConfigPath` and the active identity profile so the check
281
+ * matches exactly what {@link initConfig} would attempt to load.
282
+ *
283
+ * This is the project half of CFG-10's "is any config present?" detection; the global
284
+ * half is {@link loadGlobalRawConfig} (used by {@link hasAnyConfig}).
285
+ */
286
+ export function hasProjectConfig(commandLineConfigOverrides) {
287
+ return findProjectConfigPath(commandLineConfigOverrides) !== undefined;
288
+ }
289
+ /**
290
+ * CFG-10 — true when ANY usable configuration is present, either a project config file
291
+ * (json/js/mjs) or a standalone global config (`~/.gsloth/.gsloth.config.*`). When this
292
+ * returns false the caller should run the first-run dialog instead of erroring.
293
+ *
294
+ * Reuses CFG-8's project + global detection so the two paths can never disagree.
295
+ */
296
+ export async function hasAnyConfig(commandLineConfigOverrides) {
297
+ if (hasProjectConfig(commandLineConfigOverrides)) {
298
+ return true;
299
+ }
300
+ return (await loadGlobalRawConfig()) !== undefined;
301
+ }
302
+ /**
303
+ * Initialize configuration by loading from available config files
304
+ * @returns The loaded GthConfig
305
+ */
306
+ export async function initConfig(commandLineConfigOverrides) {
307
+ if (commandLineConfigOverrides.customConfigPath &&
308
+ !existsSync(commandLineConfigOverrides.customConfigPath)) {
309
+ throw new Error(`Provided manual config "${commandLineConfigOverrides.customConfigPath}" does not exist`);
310
+ }
311
+ // Clear the project root BEFORE discovery. Discovery and detection must resolve against cwd,
312
+ // and the up-tree walk itself goes through getGslothConfigReadPath -> getProjectDir(); clearing
313
+ // first guarantees getProjectDir() falls back to cwd during the walk, even on a SECOND initConfig
314
+ // call in a long-lived process (ACP server) or across tests where a stale projectDir would
315
+ // otherwise poison the walk and miss the real config.
316
+ setProjectDir(undefined);
317
+ // Discover the project config location: a customConfigPath wins outright, otherwise walk up
318
+ // from cwd to the stop boundary (see findProjectConfigPath). Detection and loading share this
319
+ // resolver, and the discovered dir becomes the base for the per-format cascade below.
320
+ const discovered = findProjectConfigPath(commandLineConfigOverrides);
321
+ const baseDir = discovered?.dir ?? getCurrentWorkDir();
322
+ // Set the project root for post-config, project-relative artifact resolution (guidelines,
323
+ // prompts, .gsloth-settings, outputs). up-tree and --config both set it here; a global-only /
324
+ // no-config run leaves it undefined so those artifacts stay cwd-bound (see getProjectDir).
325
+ // Safe for the in-function load below: when discovered.dir === cwd getProjectDir() is unchanged,
326
+ // and when it is an ancestor resolveConfigPath takes its explicit-dir branch (never getProjectDir).
327
+ setProjectDir(discovered?.dir);
328
+ const jsonConfigPath = commandLineConfigOverrides.customConfigPath ??
329
+ resolveConfigPath(baseDir, USER_PROJECT_CONFIG_JSON, commandLineConfigOverrides.identityProfile);
330
+ // CFG-8 — when no project config file of any format exists (anywhere up-tree), fall back to a
331
+ // standalone global config (loaded alone) before erroring. Project config still takes
332
+ // precedence: this branch only runs when there is no project file to apply the global under.
333
+ if (!discovered) {
334
+ // GS2-62 — an EXPLICITLY named identity profile (`-i <name>` / eval `--judge <name>`) that
335
+ // discovered no project config must NOT silently fall back to the global config. Doing so is a
336
+ // false-green trap: `gth -i typo …` would run under the GLOBAL model while appearing to use the
337
+ // named profile (in an authorization/eval context that hides a real misconfiguration). Fail
338
+ // loudly instead. Gated on a non-empty identityProfile, so the CFG-8 no-profile global fallback
339
+ // just below is UNTOUCHED — a run with no profile still loads the global exactly as before.
340
+ const explicitProfile = commandLineConfigOverrides.identityProfile?.trim();
341
+ if (explicitProfile) {
342
+ displayError(`identity profile "${explicitProfile}" not found: no config file in ` +
343
+ `${GSLOTH_DIR}/${GSLOTH_SETTINGS_DIR}/${explicitProfile}/ ` +
344
+ `(checked ${PROJECT_CONFIG_FORMATS.join(', ')})`);
345
+ exit(1);
346
+ // Unreachable past exit(1) in production. In specs exit() is mocked to a no-op, so this throw
347
+ // is LOAD-BEARING: without it execution would fall through into loadGlobalRawConfig() below
348
+ // and the test would observe the global silently loaded — masking the very regression this
349
+ // guard fixes. Matches the loader's existing post-exit sentinel-throw pattern.
350
+ throw new Error('Unexpected error occurred.');
351
+ }
352
+ const globalRawConfig = await loadGlobalRawConfig();
353
+ if (globalRawConfig) {
354
+ if (globalRawConfig.llm &&
355
+ typeof globalRawConfig.llm === 'object' &&
356
+ 'type' in globalRawConfig.llm) {
357
+ // Route the global config through the same path the project JSON uses.
358
+ return await tryJsonConfig(globalRawConfig, commandLineConfigOverrides);
359
+ }
360
+ displayError('Global configuration found but it is not in valid format. Should at least define llm.type');
361
+ exit(1);
362
+ // Unreachable past exit(1) in production; keeps TS happy and prevents test exit.
363
+ throw new Error('Unexpected error occurred.');
364
+ }
365
+ }
366
+ // Try loading the JSON config file first
367
+ if (jsonConfigPath.endsWith('.json') && existsSync(jsonConfigPath)) {
368
+ try {
369
+ // Validate the project config layer against the Zod schema (single source of
370
+ // truth): pre-map deprecated names, warn on unknown top-level keys, and fail
371
+ // with a friendly, path-scoped message on a genuine type mismatch.
372
+ const projectJsonConfig = validateRawConfigLayer(parseJsonc(readFileSync(jsonConfigPath, 'utf8'), USER_PROJECT_CONFIG_JSON), USER_PROJECT_CONFIG_JSON);
373
+ // Apply global config as the base layer (project config wins on conflicts).
374
+ const jsonConfig = (await applyGlobalConfigBase(projectJsonConfig));
375
+ // If the config has an LLM with a type, create the appropriate LLM instance
376
+ if (jsonConfig.llm && typeof jsonConfig.llm === 'object' && 'type' in jsonConfig.llm) {
377
+ return await tryJsonConfig(jsonConfig, commandLineConfigOverrides);
378
+ }
379
+ else {
380
+ error(`${jsonConfigPath} is not in valid format. Should at least define llm.type`);
381
+ exit(1);
382
+ // noinspection ExceptionCaughtLocallyJS
383
+ // This throw is unreachable due to exit(1) above, but satisfies TS type analysis and prevents tests from exiting
384
+ // noinspection ExceptionCaughtLocallyJS
385
+ throw new Error('Unexpected error occurred.');
386
+ }
387
+ }
388
+ catch (e) {
389
+ displayDebug(e instanceof Error ? e : String(e));
390
+ displayError(`Failed to read config from ${USER_PROJECT_CONFIG_JSON}, will try other formats.`);
391
+ // Continue to try other formats
392
+ return await tryModuleConfig('js', commandLineConfigOverrides, baseDir);
393
+ }
394
+ }
395
+ else {
396
+ // JSON config not found, try JS
397
+ return tryModuleConfig('js', commandLineConfigOverrides, baseDir);
398
+ }
399
+ }
400
+ /**
401
+ * Module-format fall-through order (lowest precedence among project formats; JSON is tried
402
+ * first by {@link initConfig}). `.ts` (B2b) is last, loaded via jiti by `importExternalFile`.
403
+ */
404
+ const MODULE_CONFIG_FORMATS = ['js', 'mjs', 'ts'];
405
+ const MODULE_CONFIG_FILENAME = {
406
+ js: USER_PROJECT_CONFIG_JS,
407
+ mjs: USER_PROJECT_CONFIG_MJS,
408
+ ts: USER_PROJECT_CONFIG_TS,
409
+ };
410
+ const MODULE_CONFIG_EXT = {
411
+ js: '.js',
412
+ mjs: '.mjs',
413
+ ts: '.ts',
414
+ };
415
+ /**
416
+ * Try loading a `configure()`-style module config (JS → MJS → TS), preserving the format
417
+ * fall-through: a missing/failed format falls through to the next in {@link MODULE_CONFIG_FORMATS};
418
+ * exhausting the chain is the terminal "no usable config" error. Collapses the formerly-duplicated
419
+ * `tryJsConfig`/`tryMjsConfig` helpers into one format-parameterized loader.
420
+ *
421
+ * NOTE: the terminal "No configuration file found" message intentionally advertises only
422
+ * json/js/mjs (the historical, asserted wording) — `.ts` is a quiet lowest-precedence fallback.
423
+ */
424
+ async function tryModuleConfig(format, commandLineConfigOverrides, baseDir) {
425
+ const filename = MODULE_CONFIG_FILENAME[format];
426
+ const ext = MODULE_CONFIG_EXT[format];
427
+ const nextFormat = MODULE_CONFIG_FORMATS[MODULE_CONFIG_FORMATS.indexOf(format) + 1];
428
+ const configPath = commandLineConfigOverrides.customConfigPath ??
429
+ resolveConfigPath(baseDir, filename, commandLineConfigOverrides.identityProfile);
430
+ if (configPath.endsWith(ext) && existsSync(configPath)) {
431
+ try {
432
+ const i = await importExternalFile(configPath);
433
+ const customConfig = validateRawConfigLayer((await i.configure()), filename);
434
+ const mergedWithGlobal = await applyGlobalConfigBase(customConfig);
435
+ return await mergeConfig(mergedWithGlobal, commandLineConfigOverrides);
436
+ }
437
+ catch (e) {
438
+ displayDebug(e instanceof Error ? e : String(e));
439
+ if (nextFormat) {
440
+ displayError(`Failed to read config from ${filename}, will try other formats.`);
441
+ // Continue to try other formats
442
+ return await tryModuleConfig(nextFormat, commandLineConfigOverrides, baseDir);
443
+ }
444
+ displayError(`Failed to read config from ${filename}.`);
445
+ displayError(`No valid configuration found. Please create a valid configuration file.`);
446
+ exit(1);
447
+ }
448
+ }
449
+ else if (nextFormat) {
450
+ // This format not found, try the next one
451
+ return await tryModuleConfig(nextFormat, commandLineConfigOverrides, baseDir);
452
+ }
453
+ else {
454
+ // No config files found
455
+ displayError('No configuration file found. Please create one of: ' +
456
+ `${USER_PROJECT_CONFIG_JSON}, ${USER_PROJECT_CONFIG_JS}, or ${USER_PROJECT_CONFIG_MJS} ` +
457
+ 'in your project directory.');
458
+ exit(1);
459
+ }
460
+ // This throw is unreachable due to exit(1) above, but satisfies TS type analysis and prevents tests from exiting
461
+ throw new Error('Unexpected error occurred.');
462
+ }
463
+ /**
464
+ * Process JSON LLM config by creating the appropriate LLM instance
465
+ * @param jsonConfig - The parsed JSON config
466
+ * @param commandLineConfigOverrides - command line config overrides
467
+ * @returns Promise<GthConfig>
468
+ */
469
+ export async function tryJsonConfig(jsonConfig, commandLineConfigOverrides) {
470
+ try {
471
+ if (jsonConfig.llm && typeof jsonConfig.llm === 'object') {
472
+ // Get the type of LLM (e.g. 'vertexai', 'anthropic') - this should exist
473
+ const llmType = jsonConfig.llm.type;
474
+ if (!llmType) {
475
+ displayError('LLM type not specified in config.');
476
+ exit(1);
477
+ }
478
+ // Get the configuration for the specific LLM type
479
+ const llmConfig = jsonConfig.llm;
480
+ if (commandLineConfigOverrides.verbose) {
481
+ // Necessary to avoid https://github.com/langchain-ai/langchainjs/issues/8705
482
+ llmConfig.verbose = commandLineConfigOverrides.verbose;
483
+ }
484
+ if (commandLineConfigOverrides.model) {
485
+ // BATCH-1 fix — see CommandLineConfigOverrides.model: retarget the raw LLM *spec* before
486
+ // the provider builds an instance from it, rather than cloning/mutating an already-built
487
+ // instance. `gth batch --models` is the only current caller.
488
+ llmConfig.model = commandLineConfigOverrides.model;
489
+ }
490
+ // Import the appropriate config module
491
+ const configModule = await import(`#src/providers/${llmType}.js`);
492
+ if (configModule.processJsonConfig) {
493
+ const llm = (await configModule.processJsonConfig(llmConfig));
494
+ const mergedConfig = mergeRawConfig(jsonConfig, llm, commandLineConfigOverrides);
495
+ if (configModule.postProcessJsonConfig) {
496
+ return await configModule.postProcessJsonConfig(mergedConfig);
497
+ }
498
+ else {
499
+ return await mergedConfig;
500
+ }
501
+ }
502
+ else {
503
+ displayWarning(`Config module for ${llmType} does not have processJsonConfig function.`);
504
+ exit(1);
505
+ }
506
+ }
507
+ else {
508
+ displayError('No LLM configuration found in config.');
509
+ exit(1);
510
+ }
511
+ }
512
+ catch (e) {
513
+ if (e instanceof Error && e.message.includes('Cannot find module')) {
514
+ displayError(`LLM type '${jsonConfig.llm.type}' not supported.`);
515
+ }
516
+ else {
517
+ displayError(`Error processing LLM config: ${e instanceof Error ? e.message : String(e)}`);
518
+ }
519
+ exit(1);
520
+ }
521
+ // This throw is unreachable due to exit(1) above, but satisfies TS type analysis and prevents tests from exiting
522
+ throw new Error('Unexpected error occurred.');
523
+ }
524
+ /**
525
+ * Config array fields whose values ADD UP across merge layers (global → project): the
526
+ * merged result is both layers concatenated (target/lower-precedence first) and de-duplicated
527
+ * by value, instead of the higher-precedence layer replacing the lower. Keyed by dotted path
528
+ * from the config root.
529
+ *
530
+ * CONSERVATIVE BY DESIGN — only genuinely-cumulative lists are additive. Everything else keeps
531
+ * REPLACE semantics, because those express "this is THE set" and silently unioning them across
532
+ * global + project would surprise users.
533
+ *
534
+ * | array field | policy | rationale |
535
+ * | -------------------- | -------- | ---------------------------------------------------- |
536
+ * | `allowDirs` | ADDITIVE | extra sandbox roots accumulate across layers |
537
+ * | `aiignore.patterns` | ADDITIVE | ignore patterns accumulate across layers |
538
+ * | `allowedTools` | replace | the explicit allow-list IS the set |
539
+ * | `builtInTools` | replace | the explicit tool selection IS the set |
540
+ * | `tools` | replace | live tool instances; union would be surprising |
541
+ * | `middleware` | replace | ordered pipeline; union would reorder/duplicate |
542
+ * | `binaryFormats` | replace | the declared format policy IS the set |
543
+ * | (every other array) | replace | default; preserves historical behaviour |
544
+ *
545
+ * NOTE: the additive fields only live at the config ROOT, so only the
546
+ * `applyGlobalConfigBase(global, project)` merge can trigger them; the per-command
547
+ * `deepMerge` calls start at command scope and never reach these paths.
548
+ *
549
+ * NAMESPACE CAVEAT: these keys are config-ROOT-relative, but the per-command
550
+ * `deepMerge(DEFAULT_CONFIG.commands.X, …)` calls also start at `path === ''`. No command
551
+ * default carries `allowDirs`/`aiignore`, so there is no collision today — but do NOT add a
552
+ * key here that could also appear as a per-command field, or it would silently become additive
553
+ * inside command merges too.
554
+ */
555
+ const ADDITIVE_ARRAY_FIELDS = new Set(['allowDirs', 'aiignore.patterns']);
556
+ /**
557
+ * Deep merge two objects, with source overriding target properties.
558
+ * Objects are merged recursively. Arrays REPLACE by default; arrays at an
559
+ * {@link ADDITIVE_ARRAY_FIELDS} path are concatenated (target-first) then de-duplicated by
560
+ * value. Every other non-plain-object value is replaced by the source value.
561
+ * @param target - The target object with default values (lower-precedence layer)
562
+ * @param source - The source object with user overrides (higher-precedence layer)
563
+ * @param path - Dotted path from the config root, used to look up the array merge policy.
564
+ */
565
+ function deepMerge(target, source, path = '') {
566
+ if (!source)
567
+ return target;
568
+ if (!target)
569
+ return source;
570
+ const result = { ...target };
571
+ for (const key in source) {
572
+ const sourceValue = source[key];
573
+ const targetValue = target[key];
574
+ const fieldPath = path ? `${path}.${key}` : key;
575
+ if (sourceValue &&
576
+ typeof sourceValue === 'object' &&
577
+ !Array.isArray(sourceValue) &&
578
+ targetValue &&
579
+ typeof targetValue === 'object' &&
580
+ !Array.isArray(targetValue)) {
581
+ // Recursively merge nested objects
582
+ result[key] = deepMerge(targetValue, sourceValue, fieldPath);
583
+ }
584
+ else if (Array.isArray(sourceValue) &&
585
+ Array.isArray(targetValue) &&
586
+ ADDITIVE_ARRAY_FIELDS.has(fieldPath)) {
587
+ // Additive list: concat both layers (target/lower-precedence first), de-dupe by value.
588
+ result[key] = [...new Set([...targetValue, ...sourceValue])];
589
+ }
590
+ else if (sourceValue !== undefined) {
591
+ // Override with source value if it exists (arrays REPLACE by default)
592
+ result[key] = sourceValue;
593
+ }
594
+ }
595
+ return result;
596
+ }
597
+ /**
598
+ * Command-scoped fields whose effective value is picked by PRECEDENCE — one layer wins wholesale
599
+ * (a per-command value REPLACES rather than extends the top-level one; none is in
600
+ * {@link ADDITIVE_ARRAY_FIELDS}). {@link resolvePrecedencePickedField} bakes the correct value into
601
+ * every command inside {@link resolveConfig}, so `getEffectiveConfig`'s later per-command-vs-root
602
+ * ternary reads an already-resolved value.
603
+ *
604
+ * GS2-60 — historically these were resolved ONLY at agent-build time (`getEffectiveConfig`), across
605
+ * two layers (per-command value vs top-level value) AFTER `DEFAULT_CONFIG` had been merged in. That
606
+ * made an explicit top-level value lose to a per-command DEFAULT: once merged, a command's
607
+ * `filesystem` is always defined (from its `'read'`/`'all'` default), so the ternary always took it
608
+ * and never fell through to the user's explicit top-level `filesystem`. Resolving here against the
609
+ * RAW (pre-default) config — where "user set it" is still distinguishable from "it's a default" —
610
+ * is the single correct site. Only `filesystem` is actually affected today (the sole field with
611
+ * per-command defaults); the other three are baked in identically for principled future-proofing.
612
+ */
613
+ const PRECEDENCE_PICKED_COMMAND_FIELDS = [
614
+ 'filesystem',
615
+ 'builtInTools',
616
+ 'allowedTools',
617
+ 'binaryFormats',
618
+ ];
619
+ /**
620
+ * Resolve one {@link PRECEDENCE_PICKED_COMMAND_FIELDS} field for one command against the RAW
621
+ * (pre-{@link DEFAULT_CONFIG}) config, highest precedence first:
622
+ *
623
+ * 1. per-command explicit : `rawConfig.commands[command][field]`
624
+ * 2. top-level explicit : `rawConfig[field]`
625
+ * 3. per-command default : `DEFAULT_CONFIG.commands[command][field]`
626
+ * 4. top-level default : `DEFAULT_CONFIG[field]`
627
+ *
628
+ * `!== undefined` at every layer so a falsy-but-EXPLICIT value (`'none'`, `[]`, `false`, `0`) is
629
+ * honoured and never mistaken for a "missing" layer. Returns `undefined` only when no layer sets
630
+ * the field (e.g. `allowedTools`/`binaryFormats` with neither a user value nor any default), in
631
+ * which case the caller leaves the command key absent — matching prior behaviour.
632
+ */
633
+ function resolvePrecedencePickedField(rawConfig, command, field) {
634
+ const rawCommands = rawConfig.commands;
635
+ const perCommandExplicit = rawCommands?.[command]?.[field];
636
+ if (perCommandExplicit !== undefined)
637
+ return perCommandExplicit;
638
+ const topLevelExplicit = rawConfig[field];
639
+ if (topLevelExplicit !== undefined)
640
+ return topLevelExplicit;
641
+ const perCommandDefault = DEFAULT_CONFIG.commands[command]?.[field];
642
+ if (perCommandDefault !== undefined)
643
+ return perCommandDefault;
644
+ return DEFAULT_CONFIG[field];
645
+ }
646
+ /**
647
+ * Resolve a fully-merged {@link GthConfig} from a partial config + CLI overrides WITHOUT
648
+ * any global side effects (a pure transform). It deep-merges defaults, applies CLI overrides,
649
+ * resolves the numeric `consoleLevel` (warning + defaulting to INFO on an invalid value), and
650
+ * computes `canInterruptInferenceWithEsc`. The process-global setters (`setUseColour` /
651
+ * `setConsoleLevel`) are applied separately by {@link mergeConfig}, so this function can be
652
+ * reasoned about and reused without touching global state.
653
+ */
654
+ export function resolveConfig(partialConfig, commandLineConfigOverrides) {
655
+ const config = partialConfig;
656
+ // Deep merge command configs while preserving defaults
657
+ // Type complexity from DEFAULT_CONFIG.commands 'as const' requires any cast for deep merge result
658
+ const mergedCommands = {
659
+ pr: deepMerge(DEFAULT_CONFIG.commands.pr, config?.commands?.pr), // eslint-disable-line @typescript-eslint/no-explicit-any
660
+ review: deepMerge(DEFAULT_CONFIG.commands.review, config?.commands?.review), // eslint-disable-line @typescript-eslint/no-explicit-any
661
+ code: deepMerge(DEFAULT_CONFIG.commands.code, config?.commands?.code), // eslint-disable-line @typescript-eslint/no-explicit-any
662
+ exec: deepMerge(DEFAULT_CONFIG.commands.exec, config?.commands?.exec), // eslint-disable-line @typescript-eslint/no-explicit-any
663
+ ask: deepMerge(DEFAULT_CONFIG.commands.ask, config?.commands?.ask), // eslint-disable-line @typescript-eslint/no-explicit-any
664
+ chat: deepMerge(DEFAULT_CONFIG.commands.chat, config?.commands?.chat), // eslint-disable-line @typescript-eslint/no-explicit-any
665
+ api: deepMerge(DEFAULT_CONFIG.commands.api, config?.commands?.api), // eslint-disable-line @typescript-eslint/no-explicit-any
666
+ };
667
+ // GS2-60 — bake the correct 4-layer precedence for the precedence-picked fields
668
+ // (filesystem/builtInTools/allowedTools/binaryFormats) into each command, resolved against the
669
+ // RAW `config` (still pre-DEFAULT_CONFIG here). The per-command `deepMerge` above only ranks
670
+ // per-command explicit vs per-command DEFAULT and cannot see the user's explicit top-level value
671
+ // — so without this an explicit top-level `filesystem` was silently lost to a command's default.
672
+ // A fresh `{ ...base, ...overrides }` per command is REQUIRED: `deepMerge` returns the live
673
+ // `DEFAULT_CONFIG.commands[cmd]` reference verbatim when the user configured nothing for that
674
+ // command, so in-place mutation would corrupt the shared default across every subsequent call.
675
+ const commandsRecord = mergedCommands;
676
+ for (const command of Object.keys(commandsRecord)) {
677
+ const base = commandsRecord[command];
678
+ const overrides = {};
679
+ for (const field of PRECEDENCE_PICKED_COMMAND_FIELDS) {
680
+ const resolved = resolvePrecedencePickedField(config, command, field);
681
+ // Leave the key absent when no layer set it (preserves prior behaviour for e.g. a command
682
+ // with no allowedTools anywhere), rather than writing an explicit `undefined`.
683
+ if (resolved !== undefined) {
684
+ overrides[field] = resolved;
685
+ }
686
+ }
687
+ commandsRecord[command] = { ...base, ...overrides };
688
+ }
689
+ const mergedConfig = {
690
+ ...DEFAULT_CONFIG,
691
+ ...config,
692
+ commands: mergedCommands,
693
+ };
694
+ if (commandLineConfigOverrides.identityProfile !== undefined) {
695
+ displayInfo(`Activating profile: ${commandLineConfigOverrides.identityProfile}`);
696
+ mergedConfig.identityProfile = commandLineConfigOverrides.identityProfile.trim();
697
+ }
698
+ if (commandLineConfigOverrides.verbose !== undefined) {
699
+ mergedConfig.llm.verbose = commandLineConfigOverrides.verbose;
700
+ }
701
+ if (commandLineConfigOverrides.writeOutputToFile !== undefined) {
702
+ mergedConfig.writeOutputToFile = commandLineConfigOverrides.writeOutputToFile;
703
+ }
704
+ // Resolve console logging level (value only; the global setter is applied in mergeConfig).
705
+ if (mergedConfig.consoleLevel !== undefined) {
706
+ const resolvedConsoleLevel = resolveConsoleLevel(mergedConfig.consoleLevel);
707
+ if (resolvedConsoleLevel !== undefined) {
708
+ mergedConfig.consoleLevel = resolvedConsoleLevel;
709
+ }
710
+ else {
711
+ displayWarning(`Invalid consoleLevel "${String(mergedConfig.consoleLevel)}", using default ${StatusLevel.INFO}.`);
712
+ mergedConfig.consoleLevel = StatusLevel.INFO;
713
+ }
714
+ }
715
+ mergedConfig.canInterruptInferenceWithEsc = mergedConfig.canInterruptInferenceWithEsc && isTTY();
716
+ return mergedConfig;
717
+ }
718
+ /**
719
+ * Merge config with default config, then apply the resolved colour + console-level settings
720
+ * to the process globals. Thin wrapper over the pure {@link resolveConfig}; kept `async` with
721
+ * the same signature so every existing caller (`mergeRawConfig`, `tryJsConfig`, `tryMjsConfig`)
722
+ * behaves identically — the two `set*` calls are the only global mutations in the merge path.
723
+ */
724
+ async function mergeConfig(partialConfig, commandLineConfigOverrides) {
725
+ const mergedConfig = resolveConfig(partialConfig, commandLineConfigOverrides);
726
+ // Set the useColour value in systemUtils.
727
+ setUseColour(mergedConfig.useColour);
728
+ // Set console logging level.
729
+ if (mergedConfig.consoleLevel !== undefined) {
730
+ setConsoleLevel(mergedConfig.consoleLevel);
731
+ }
732
+ return mergedConfig;
733
+ }
734
+ const CONSOLE_LEVELS_BY_NAME = {
735
+ debug: StatusLevel.DEBUG,
736
+ info: StatusLevel.INFO,
737
+ display: StatusLevel.DISPLAY,
738
+ success: StatusLevel.SUCCESS,
739
+ warning: StatusLevel.WARNING,
740
+ error: StatusLevel.ERROR,
741
+ stream: StatusLevel.STREAM,
742
+ };
743
+ function resolveConsoleLevel(level) {
744
+ if (typeof level === 'number') {
745
+ return StatusLevel[level] !== undefined ? level : undefined;
746
+ }
747
+ if (typeof level === 'string') {
748
+ const normalized = level.trim().toLowerCase();
749
+ if (normalized in CONSOLE_LEVELS_BY_NAME) {
750
+ return CONSOLE_LEVELS_BY_NAME[normalized];
751
+ }
752
+ const enumValue = StatusLevel[level];
753
+ if (typeof enumValue === 'number') {
754
+ return enumValue;
755
+ }
756
+ }
757
+ return undefined;
758
+ }
759
+ /**
760
+ * Read a raw config object from an on-disk path WITHOUT validating, building an LLM, or
761
+ * merging defaults. JSON/JSONC files are parsed leniently ({@link parseJsonc}); module
762
+ * formats (`.js`/`.mjs`/`.ts`) are imported and their `configure()` invoked. Used by the
763
+ * read-side {@link validateConfig}; may throw on a parse/module error (surfaced by the caller).
764
+ */
765
+ async function readRawConfigAtPath(path) {
766
+ if (path.endsWith('.json') || path.endsWith('.jsonc')) {
767
+ return parseJsonc(readFileSync(path, 'utf8'), path);
768
+ }
769
+ const imported = await importExternalFile(path);
770
+ return (await imported.configure());
771
+ }
772
+ /**
773
+ * Global config read for the read-side {@link validateConfig}: mirrors
774
+ * {@link loadGlobalRawConfig}'s lookup order (JSON → JS → MJS) but does NOT validate or
775
+ * `exit` — it just returns the raw object + a source label so the validator owns the verdict.
776
+ *
777
+ * Ignore-on-error, exactly like {@link loadGlobalRawConfig}: a parse/module failure of the
778
+ * global file is treated as an ABSENT global (returns `undefined`), NOT a hard error — a real
779
+ * run does the same, so the diagnostic must too, else a clean project + an unparseable global
780
+ * would fail `gth config validate` while the run keeps going (the inverse of the GS2-29 bug).
781
+ *
782
+ * The failure is BOTH debug-logged AND surfaced as a user-facing `displayWarning` with the same
783
+ * message `loadGlobalRawConfig` emits (`Failed to read global config from <path>, ignoring it.`).
784
+ * A run warns the user while ignoring the broken global's VALUE; matching that message is what
785
+ * keeps `gth config validate` a faithful mirror of the run rather than staying silent about a
786
+ * problem the run flags.
787
+ */
788
+ async function loadGlobalRawConfigUnvalidated() {
789
+ const jsonPath = getGlobalGslothConfigReadPath(USER_PROJECT_CONFIG_JSON);
790
+ if (existsSync(jsonPath)) {
791
+ const label = `${USER_PROJECT_CONFIG_JSON} (global)`;
792
+ try {
793
+ return {
794
+ raw: parseJsonc(readFileSync(jsonPath, 'utf8'), label),
795
+ label,
796
+ };
797
+ }
798
+ catch (e) {
799
+ displayDebug(e instanceof Error ? e : String(e));
800
+ displayWarning(`Failed to read global config from ${jsonPath}, ignoring it.`);
801
+ return undefined;
802
+ }
803
+ }
804
+ for (const filename of [USER_PROJECT_CONFIG_JS, USER_PROJECT_CONFIG_MJS]) {
805
+ const modulePath = getGlobalGslothConfigReadPath(filename);
806
+ if (existsSync(modulePath)) {
807
+ try {
808
+ const imported = await importExternalFile(modulePath);
809
+ return {
810
+ raw: (await imported.configure()),
811
+ label: `${filename} (global)`,
812
+ };
813
+ }
814
+ catch (e) {
815
+ displayDebug(e instanceof Error ? e : String(e));
816
+ displayWarning(`Failed to read global config from ${modulePath}, ignoring it.`);
817
+ return undefined;
818
+ }
819
+ }
820
+ }
821
+ return undefined;
822
+ }
823
+ /**
824
+ * Locate and validate the effective raw config against the schema WITHOUT building the LLM
825
+ * or merging defaults (the read-side of GS2-1). Honours `--config`, up-tree discovery, and the
826
+ * identity profile via {@link findProjectConfigPath}.
827
+ *
828
+ * GS2-29 — validates the SAME layer set a real run does: the discovered PROJECT layer (if any)
829
+ * AND the GLOBAL layer (if any), mirroring `initConfig`'s `validateRawConfigLayer(project)` +
830
+ * `applyGlobalConfigBase` → `loadGlobalRawConfig(global)`. Each present layer is validated
831
+ * independently ({@link validateRawGthConfig}) and its outcome recorded in {@link
832
+ * ConfigValidationReport.layers}, so a removed shape in EITHER file is reported (labelled with
833
+ * its source) rather than under-reported.
834
+ *
835
+ * A PROJECT-layer JSONC/module parse failure is thrown to the caller (surfaced as a clear
836
+ * "invalid config" error + non-zero exit). A GLOBAL-layer parse failure is treated as an absent
837
+ * global (no layer added) but is surfaced with a `displayWarning` — exactly as a run does (it
838
+ * warns the user while ignoring the broken global's value) — see {@link
839
+ * loadGlobalRawConfigUnvalidated}.
840
+ */
841
+ export async function validateConfig(commandLineConfigOverrides) {
842
+ const layers = [];
843
+ // Project layer first (run order): initConfig validates the discovered project config before
844
+ // applying the global base. A parse failure here propagates (surfaced by the caller).
845
+ const discovered = findProjectConfigPath(commandLineConfigOverrides);
846
+ if (discovered) {
847
+ const raw = await readRawConfigAtPath(discovered.path);
848
+ layers.push({ sourceLabel: discovered.path, ...validateRawGthConfig(raw) });
849
+ }
850
+ // Global layer next: a run ALWAYS applies it (applyGlobalConfigBase in the project path,
851
+ // loadGlobalRawConfig in the no-project path), so the diagnostic must validate it too — this is
852
+ // the layer the previous single-layer validateConfig skipped whenever a project config existed.
853
+ const globalRaw = await loadGlobalRawConfigUnvalidated();
854
+ if (globalRaw) {
855
+ layers.push({ sourceLabel: globalRaw.label, ...validateRawGthConfig(globalRaw.raw) });
856
+ }
857
+ // Vacuous-truth guard: `every` is true on an empty array, so gate `ok` on a config existing.
858
+ return { found: layers.length > 0, ok: layers.length > 0 && layers.every((l) => l.ok), layers };
859
+ }
860
+ /**
861
+ * Merge raw with default config
862
+ */
863
+ async function mergeRawConfig(config, llm, commandLineConfigOverrides) {
864
+ const modelDisplayName = config.llm?.model;
865
+ // GS2-53 — stash the raw provider `type` (openrouter/deepseek/xai/…) BEFORE the built `llm`
866
+ // replaces the raw `llm` spec below: it is the true configured provider and the only place the
867
+ // OpenAI-compatible shims' real identity survives (their `_llmType()` reports `openai`).
868
+ // `resolveModelIdentity` prefers it over `_llmType()`. INTERNAL field (not in the config schema).
869
+ const modelProviderType = config.llm?.type;
870
+ return await mergeConfig({ ...config, llm, modelDisplayName, modelProviderType }, commandLineConfigOverrides);
871
+ }
872
+ //# sourceMappingURL=loader.js.map