@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
@@ -6,15 +6,22 @@
6
6
  * JSON Schema (`packages/core/schema/gsloth-config.schema.json`). It models the
7
7
  * canonical config shape only — the deprecated aliases (`contentProvider`,
8
8
  * `requirementsProvider`, `contentProviderConfig`, `requirementsProviderConfig`)
9
- * are intentionally NOT part of the schema. The loader maps those one-way to the
10
- * canonical names ({@link preMapDeprecatedConfigNames}) BEFORE validating, so by
11
- * the time a config reaches the schema it only uses canonical names.
9
+ * are intentionally NOT part of the schema. GS2-28 (a 2.0 breaking release with NO
10
+ * back-compat coercion) turns those deprecated shapes into HARD validation errors:
11
+ * {@link findDeprecatedConfigIssues} detects them on the raw input BEFORE the schema
12
+ * parse and each entry point (loader `validateRawConfigLayer`, read-side
13
+ * {@link validateRawGthConfig}) rejects the config with a message naming the canonical
14
+ * replacement + migration path. Detection runs on the RAW input on purpose: zod's
15
+ * per-command `z.object` strips unknown keys, so a nested `commands.*.contentProvider`
16
+ * would vanish before any schema-embedded check could see it.
12
17
  *
13
18
  * Design notes:
14
19
  * - The top-level object is a {@link z.looseObject} so unknown keys PASS THROUGH
15
20
  * (they are neither stripped nor a hard failure). The loader separately diffs
16
21
  * present-vs-known top-level keys ({@link findUnknownTopLevelKeys}) to warn about
17
- * likely typos without failing.
22
+ * likely typos without failing. That typo-tolerance is deliberate: only the KNOWN
23
+ * deprecated names ({@link findDeprecatedConfigIssues}) hard-fail; a genuine typo
24
+ * (e.g. `pulrequest`) still only warns.
18
25
  * - Every field is optional. The schema validates the *shape/type of what is
19
26
  * present*, not requiredness. In particular `llm` is optional so the loader's
20
27
  * existing "must at least define llm.type" checks remain the authority on llm
@@ -28,7 +35,24 @@
28
35
  * is additive and legitimately differs (no deprecated fields).
29
36
  */
30
37
  import { z } from 'zod';
38
+ // `constants.ts` is a plain, import-free string module, so this does NOT compromise the purity
39
+ // this file depends on (it feeds `z.toJSONSchema` and must stay cwd/fs-independent).
40
+ import { GSLOTH_DIR, GSLOTH_SETTINGS_DIR } from '#src/constants.js';
31
41
  const filesystemSchema = z.union([z.array(z.string()), z.enum(['all', 'read', 'none'])]);
42
+ /**
43
+ * TLS trust for outbound HTTPS (MCP servers over `http` transport, and — because the mechanism is
44
+ * a process-global undici dispatcher — every other `fetch`, including LLM provider calls). Lets a
45
+ * config point Node's `fetch` at a private/corporate CA without prepending `NODE_EXTRA_CA_CERTS`
46
+ * on every invocation (that env var is read once at Node startup and can't be set from config).
47
+ */
48
+ const tlsSchema = z.object({
49
+ // Extra CA cert file(s) to TRUST in ADDITION to Node's built-in roots. Paths resolve
50
+ // relative-to-project (or `~`/absolute). Additive — this never removes a default root.
51
+ extraCaCerts: z.array(z.string()).optional(),
52
+ // DANGER: `false` disables TLS certificate verification for ALL outbound HTTPS this process makes
53
+ // (not just MCP — LLM calls too). Escape hatch only; emits a loud security warning every session.
54
+ rejectUnauthorized: z.boolean().optional(),
55
+ });
32
56
  const llmConfigSchema = z.looseObject({
33
57
  type: z.string().optional(),
34
58
  model: z.string().optional(),
@@ -63,39 +87,564 @@ const binaryFormatConfigSchema = z.object({
63
87
  mimeTypes: z.record(z.string(), z.string()).optional(),
64
88
  });
65
89
  const binaryFormatsSchema = z.union([z.literal(false), z.array(binaryFormatConfigSchema)]);
66
- const shellJudgeSchema = z.union([
67
- z.boolean(),
90
+ /**
91
+ * CFG-27 — the five rungs of the approvals ladder, as the schema sees them. Kept as a literal
92
+ * tuple here (rather than imported from `shell-policy.ts`) so the schema module stays the single
93
+ * pre-parse source of truth for what the config channel accepts; `APPROVAL_RUNGS` in
94
+ * `shell-policy.ts` is the runtime twin and `configSchema.spec.ts` pins the two together.
95
+ */
96
+ const APPROVAL_RUNG_VALUES = ['manual', 'write', 'assisted', 'auto', 'bypass'];
97
+ /**
98
+ * The rung names as an error message lists them. Derived from {@link APPROVAL_RUNG_VALUES} rather
99
+ * than written out again at each site: four messages below enumerate the ladder, and a hand-copied
100
+ * list is exactly what goes stale when a rung is renamed — the failure mode being that a config
101
+ * error names values the schema no longer accepts.
102
+ */
103
+ const APPROVAL_RUNG_LIST = APPROVAL_RUNG_VALUES.join(', ');
104
+ /**
105
+ * GS2-93 — the three rungs `output.header` accepts, in increasing verbosity: `none` opens a run
106
+ * with nothing at all (including the review attribution), `compact` with one attribution line, and
107
+ * `debug` with the full technical preamble. `compact` is the default, so an unset key opens with
108
+ * the single run-header line and the preamble is opt-in.
109
+ */
110
+ export const OUTPUT_HEADER_RUNGS = ['none', 'compact', 'debug'];
111
+ /**
112
+ * EXT-117 — the commands an ACP (editor) session may be resolved under, as `acp.mode`. Both are
113
+ * existing members of `GthCommand`, which is the whole point of the key: the value is a command the
114
+ * tool-gating branches already understand rather than a new one they would each have to learn. The
115
+ * read-site default is `code` ({@link import('#src/config/types.js').GthConfig.acp}).
116
+ */
117
+ export const ACP_SESSION_MODES = ['chat', 'code'];
118
+ /**
119
+ * The message a rejected `output.header` carries.
120
+ *
121
+ * The key was a boolean before it was a ladder, and 2.0 has NO back-compat coercion — a boolean is
122
+ * a hard validation failure exactly as `rating: false` and the retired approval modes are. What
123
+ * makes that survivable is that the error names the rung that replaces the value the user actually
124
+ * wrote, rather than only listing the vocabulary: someone who wrote `false` wanted silence and is
125
+ * told `none`, someone who wrote `true` wanted the preamble and is told `debug`.
126
+ *
127
+ * Neither branch names the key. Every renderer of this message puts it behind the issue's path
128
+ * (`formatIssueLines`, so ` - output.header: …`), and the path here is always exactly
129
+ * `output.header` — repeating it in the sentence only stutters.
130
+ */
131
+ function describeBadHeaderRung(issue) {
132
+ if (typeof issue.input === 'boolean') {
133
+ return (`no longer a boolean: it is one of ${OUTPUT_HEADER_RUNGS.join(', ')}. ` +
134
+ `Use "${issue.input ? 'debug' : 'none'}" instead of ${issue.input}.`);
135
+ }
136
+ return (`${JSON.stringify(issue.input)} is not a run-header rung — the rungs are ` +
137
+ `${OUTPUT_HEADER_RUNGS.join(', ')}.`);
138
+ }
139
+ /**
140
+ * EXT-71 §3.1 — the **subject** axis of a rule entry, and only that: `shell` is a command, `tool`
141
+ * a built-in or custom in-process tool, `mcpTool` a server's tool. The hand-written twin is
142
+ * `ApprovalEntryType` in `shell-policy.ts`. What holds the two together is
143
+ * `approvalEntrySchema.spec.ts`, where a list of `ApprovalEntry`-typed literals is parsed by this
144
+ * schema: a value either side stops accepting fails there. That is a weaker pin than a direct
145
+ * equality assertion — it catches a narrowing, not a widening on one side alone.
146
+ */
147
+ export const APPROVAL_ENTRY_TYPES = ['shell', 'tool', 'mcpTool'];
148
+ /**
149
+ * EXT-71 §3.1 — the **comparison** axis of a rule entry, and only that. `exact`/`glob`/`regexp`
150
+ * take a string pattern; `hint` takes an object over the annotation names and is valid on tool
151
+ * subjects only (on `shell` it is a config error — see {@link shellEntrySchema}).
152
+ */
153
+ export const APPROVAL_ENTRY_MATCHERS = ['exact', 'glob', 'regexp', 'hint'];
154
+ /**
155
+ * EXT-71 §3.1 / §4.7 — the four MCP `ToolAnnotations` booleans a `hint` pattern may name, and the
156
+ * names a user may list in `approvals.mcp.*.trustAnnotations` (§4.7.1). This is the whole
157
+ * vocabulary: an unknown name is a config error, never an ignored key, because a hint pattern that
158
+ * quietly drops a constraint matches MORE than its author wrote, and a trust list that quietly
159
+ * drops one reads as working while believing something else.
160
+ *
161
+ * **The runtime twin `TOOL_ANNOTATION_HINTS` in `shell-policy.ts` is a deliberate duplicate, and
162
+ * the reason is layering, not oversight.** Neither file may import the other. This module must stay
163
+ * pure and cwd/fs-independent because it feeds `z.toJSONSchema()` (see the header), and importing
164
+ * `shell-policy.ts` would pull `core/types.js` and the whole runtime policy surface into it;
165
+ * importing this module from there would in turn pull zod into every module that only wanted a
166
+ * policy type. So the vocabulary is written once per layer on purpose — do not "simplify" it by
167
+ * making one import the other. The equality assertion in `mcpApprovalsBlock.spec.ts` is what fails
168
+ * when they drift.
169
+ */
170
+ export const HINT_ANNOTATION_KEYS = [
171
+ 'readOnlyHint',
172
+ 'destructiveHint',
173
+ 'idempotentHint',
174
+ 'openWorldHint',
175
+ ];
176
+ /**
177
+ * EXT-71 §3.1 — the length cap on a `regexp` pattern, enforced when the config LOADS.
178
+ *
179
+ * 200 characters. The longest pattern the spec itself writes is under 40, and every rule entry
180
+ * names one command or tool shape rather than a grammar, so 200 is an order of magnitude of
181
+ * headroom over real use while still bounding what the matcher can ever be handed. The cap is a
182
+ * cheap load-time bound, NOT a backtracking defence — a short pattern can backtrack
183
+ * catastrophically too, and the run-time match budget is the separate backstop for that. What the
184
+ * cap buys is that a pattern nobody could have read and reviewed cannot be smuggled past load.
185
+ */
186
+ export const APPROVAL_REGEXP_MAX_LENGTH = 200;
187
+ /**
188
+ * EXT-71 §3.1 — a `hint` pattern: an object over {@link HINT_ANNOTATION_KEYS} mapping each named
189
+ * annotation to the boolean it must effectively hold. All named hints must match (AND within the
190
+ * entry); hints not named are unconstrained; `false` is the spelling of negation.
191
+ *
192
+ * Strict, and non-empty: an empty object or an unknown name is a hard config error and **never a
193
+ * match-everything**. `minProperties` is attached as metadata rather than being left to the
194
+ * refinement alone so the constraint survives into the emitted JSON Schema (zod drops refinements
195
+ * there), which is what the hosted schema channels and editor validation actually read.
196
+ */
197
+ const hintPatternSchema = z
198
+ .strictObject({
199
+ readOnlyHint: z.boolean().optional(),
200
+ destructiveHint: z.boolean().optional(),
201
+ idempotentHint: z.boolean().optional(),
202
+ openWorldHint: z.boolean().optional(),
203
+ })
204
+ .meta({ minProperties: 1 })
205
+ .refine((pattern) => Object.keys(pattern).length > 0, {
206
+ message: 'a hint pattern must name at least one of ' +
207
+ HINT_ANNOTATION_KEYS.join(', ') +
208
+ ' — an empty object is a config error, never a match-everything',
209
+ });
210
+ /**
211
+ * EXT-71 §3.1 — a `regexp` pattern: capped at {@link APPROVAL_REGEXP_MAX_LENGTH} and required to
212
+ * COMPILE when the config loads, never when it first runs. Both failures name the offending
213
+ * pattern in the message: an entry the user cannot trace back to the line they wrote is
214
+ * indistinguishable from a bug.
215
+ *
216
+ * `.max()` is kept alongside the refinement so the cap emits as `maxLength` in the JSON Schema
217
+ * (refinements do not survive `z.toJSONSchema`); the refinement is what produces the message that
218
+ * quotes the pattern.
219
+ */
220
+ const regexpPatternSchema = z
221
+ .string()
222
+ .max(APPROVAL_REGEXP_MAX_LENGTH)
223
+ .superRefine((pattern, ctx) => {
224
+ if (pattern.length > APPROVAL_REGEXP_MAX_LENGTH) {
225
+ ctx.addIssue({
226
+ code: 'custom',
227
+ message: `regexp pattern ${JSON.stringify(pattern)} is ${pattern.length} characters, over the ` +
228
+ `${APPROVAL_REGEXP_MAX_LENGTH}-character cap for an approvals rule pattern`,
229
+ });
230
+ return;
231
+ }
232
+ try {
233
+ new RegExp(pattern);
234
+ }
235
+ catch (e) {
236
+ ctx.addIssue({
237
+ code: 'custom',
238
+ message: `regexp pattern ${JSON.stringify(pattern)} does not compile: ` +
239
+ `${e instanceof Error ? e.message : String(e)}`,
240
+ });
241
+ }
242
+ });
243
+ /** `rate` (§3.2) — optional on EVERY entry type, and the only optional field they all share. */
244
+ const rateField = { rate: z.boolean().optional() };
245
+ /**
246
+ * `host` (§4.7.4) — optional on TOOL subjects only, exact-match. Forbidden on `shell`, where the
247
+ * host is already inside the command string; the shell arms are strict objects, so writing it
248
+ * there is an unrecognized-key error.
249
+ */
250
+ const hostField = { host: z.string().min(1).optional() };
251
+ /**
252
+ * EXT-71 §3.1 — a `shell` entry. `matcher` deliberately omits `hint`: a command carries no tool
253
+ * annotations, so `{"type":"shell","matcher":"hint"}` is a discriminator error naming the three
254
+ * matchers a command actually supports. Neither `server` nor `host` exists here.
255
+ */
256
+ const shellEntrySchema = z.discriminatedUnion('matcher', [
257
+ z.strictObject({
258
+ type: z.literal('shell'),
259
+ matcher: z.literal('exact'),
260
+ pattern: z.string(),
261
+ ...rateField,
262
+ }),
263
+ z.strictObject({
264
+ type: z.literal('shell'),
265
+ matcher: z.literal('glob'),
266
+ pattern: z.string(),
267
+ ...rateField,
268
+ }),
269
+ z.strictObject({
270
+ type: z.literal('shell'),
271
+ matcher: z.literal('regexp'),
272
+ pattern: regexpPatternSchema,
273
+ ...rateField,
274
+ }),
275
+ ]);
276
+ /** EXT-71 §3.1 — a `tool` entry: a built-in or custom in-process tool, matched on its name. */
277
+ const toolEntrySchema = z.discriminatedUnion('matcher', [
278
+ z.strictObject({
279
+ type: z.literal('tool'),
280
+ matcher: z.literal('exact'),
281
+ pattern: z.string(),
282
+ ...hostField,
283
+ ...rateField,
284
+ }),
285
+ z.strictObject({
286
+ type: z.literal('tool'),
287
+ matcher: z.literal('glob'),
288
+ pattern: z.string(),
289
+ ...hostField,
290
+ ...rateField,
291
+ }),
292
+ z.strictObject({
293
+ type: z.literal('tool'),
294
+ matcher: z.literal('regexp'),
295
+ pattern: regexpPatternSchema,
296
+ ...hostField,
297
+ ...rateField,
298
+ }),
299
+ z.strictObject({
300
+ type: z.literal('tool'),
301
+ matcher: z.literal('hint'),
302
+ pattern: hintPatternSchema,
303
+ ...hostField,
304
+ ...rateField,
305
+ }),
306
+ ]);
307
+ /**
308
+ * EXT-71 §3.1 — the reserved MCP server name. `*` in an entry's `server` field means *every
309
+ * server*, so a server actually CALLED `*` would make `{ "server": "*" }` ambiguous — it could not
310
+ * be read as either "every server" or "that one server" without picking, and a rule whose scope
311
+ * depends on which reading won is worse than no rule. The name is therefore refused at load.
312
+ */
313
+ const RESERVED_MCP_SERVER_NAME = '*';
314
+ /**
315
+ * EXT-78 §4.7.5 — the key an MCP server may be configured under, and the one spelling of that rule
316
+ * a JSON Schema can carry. It states exactly what {@link findApprovalsGrammarIssues} refuses ahead
317
+ * of the parse: a name of at least one character (nothing could be written about a server keyed
318
+ * with the empty string) and any name other than {@link RESERVED_MCP_SERVER_NAME}.
319
+ *
320
+ * It is on the record's KEY so the emitted schema carries both rules as `propertyNames`
321
+ * (`minLength` plus `pattern`). Without them the hosted channels publish a contract that accepts
322
+ * two keys the CLI then refuses to start on, and an editor validating against that contract stays
323
+ * silent while the error arrives from somewhere the user is not looking — the one direction they
324
+ * cannot debug from where they are working.
325
+ *
326
+ * The exclusion is EXACT, matching the pre-parse check's own equality test: a longer name that
327
+ * merely begins with the reserved one (`*-jira`) is an ordinary name and stays valid. And the
328
+ * pre-parse check still runs first and still owns the message that explains *why* each key is
329
+ * refused, which a JSON Schema violation can never say.
330
+ */
331
+ /**
332
+ * The exclusion, spelt **without lookaround**, because this pattern is published rather than merely
333
+ * executed here.
334
+ *
335
+ * JSON Schema's `pattern` is nominally ECMA-262, but real validators differ and the ones built on
336
+ * RE2 or Rust's `regex` **cannot compile a lookaround at all** — they reject the document rather
337
+ * than mis-evaluating one keyword. A `^(?!\*$)` spelling therefore risks taking the *whole* hosted
338
+ * schema out of service in those editors, which is a worse failure than the gap this rule closes and
339
+ * defeats the reason the rule exists.
340
+ *
341
+ * Read it as: any single character that is not the reserved one, **or** any string of two or more
342
+ * characters. Only the exact reserved name is left out, which is the pre-parse check's own equality
343
+ * test. The empty string is excluded by `minLength` instead, which is the clearer home for it.
344
+ *
345
+ * **`[\s\S]` and not `.`** — `.` does not match a newline, so a `.`-based spelling refuses a key
346
+ * containing one while the load accepts it. That is a fresh divergence in the opposite direction:
347
+ * an editor red on a config that starts fine, i.e. this rule's own defect class, reintroduced by
348
+ * the fix for it. Measured on `"a\nb"` and `"\n\n"`.
349
+ *
350
+ * The construction assumes a **single-character** reserved name — with a longer one, "not exactly
351
+ * this string" is not expressible as one negated class. {@link mcpServerNameSchema}'s spec asserts
352
+ * that assumption, so changing the constant fails loudly instead of silently emitting a pattern that
353
+ * refuses the wrong set.
354
+ */
355
+ const MCP_SERVER_NAME_PATTERN = `^([^${RESERVED_MCP_SERVER_NAME.replace(/[\\\]^-]/g, '\\$&')}]|[\\s\\S]{2,})$`;
356
+ const mcpServerNameSchema = z.string().min(1).regex(new RegExp(MCP_SERVER_NAME_PATTERN));
357
+ /**
358
+ * EXT-71 §3.1 — an `mcpTool` entry. `server` is **required** here and exists nowhere else: it is
359
+ * the user's own key in `mcpServers` (§4.7.5), the only stable, unique, user-authored identity a
360
+ * server has. The literal `*` is reserved to mean every server, which is why a configured server
361
+ * may not be named `*` ({@link findApprovalsGrammarIssues}).
362
+ */
363
+ const mcpToolEntrySchema = z.discriminatedUnion('matcher', [
364
+ z.strictObject({
365
+ type: z.literal('mcpTool'),
366
+ matcher: z.literal('exact'),
367
+ server: z.string().min(1),
368
+ pattern: z.string(),
369
+ ...hostField,
370
+ ...rateField,
371
+ }),
372
+ z.strictObject({
373
+ type: z.literal('mcpTool'),
374
+ matcher: z.literal('glob'),
375
+ server: z.string().min(1),
376
+ pattern: z.string(),
377
+ ...hostField,
378
+ ...rateField,
379
+ }),
380
+ z.strictObject({
381
+ type: z.literal('mcpTool'),
382
+ matcher: z.literal('regexp'),
383
+ server: z.string().min(1),
384
+ pattern: regexpPatternSchema,
385
+ ...hostField,
386
+ ...rateField,
387
+ }),
388
+ z.strictObject({
389
+ type: z.literal('mcpTool'),
390
+ matcher: z.literal('hint'),
391
+ server: z.string().min(1),
392
+ pattern: hintPatternSchema,
393
+ ...hostField,
394
+ ...rateField,
395
+ }),
396
+ ]);
397
+ /**
398
+ * EXT-71 §3.1 — **one** entry in `allow`, `deny` or `escalate`. All three lists take the same
399
+ * shape, so there is one schema and the list a rule sits in decides only what a match DOES.
400
+ *
401
+ * `type`, `matcher` and `pattern` are required on every arm: no field is inferred, and no entry
402
+ * reads two ways. Every arm is a strict object, so any field the grammar does not define — a typo,
403
+ * a `server` on a `shell` entry, a `host` on a `shell` entry — is an unrecognized-key error rather
404
+ * than a silently-ignored key that would widen what the entry matches.
405
+ *
406
+ * The `id` is what makes the emitted JSON Schema hoist this union into `$defs` and reference it,
407
+ * instead of inlining all eleven arms into each of the twenty-four places a rule list appears
408
+ * (three lists × the root plus seven commands). That is the difference between a schema an editor loads
409
+ * and one it chokes on, and `$ref` is the standard spelling every JSON Schema consumer already
410
+ * understands.
411
+ */
412
+ export const approvalEntrySchema = z
413
+ .discriminatedUnion('type', [shellEntrySchema, toolEntrySchema, mcpToolEntrySchema])
414
+ .meta({ id: 'ApprovalEntry' });
415
+ /** The three rule lists, keyed as they appear under `approvals` (§3, §9). */
416
+ const APPROVAL_LIST_KEYS = ['allow', 'deny', 'escalate'];
417
+ /**
418
+ * EXT-70 §4.7.1/§9.1 — `trustAnnotations`: **the hints believed from one server**, as a LIST of
419
+ * {@link HINT_ANNOTATION_KEYS} names and never a boolean, because trusting `readOnlyHint` while
420
+ * distrusting `openWorldHint` is a coherent position and the common one.
421
+ *
422
+ * An unknown name is a hard config error rather than an ignored member: a list that quietly drops
423
+ * a name the user believed they wrote trusts LESS than they asked in one direction and reads as a
424
+ * working config in the other, and a trust list nobody can verify from its own error output is
425
+ * worse than none. The custom message quotes the offending value, since the path alone gives an
426
+ * index and the user needs the word they mistyped.
427
+ */
428
+ const trustAnnotationsSchema = z.array(z.enum(HINT_ANNOTATION_KEYS, {
429
+ error: (issue) => `${JSON.stringify(issue.input)} is not an MCP tool annotation. trustAnnotations names the ` +
430
+ `hints believed from a server, and the whole vocabulary is ` +
431
+ `${HINT_ANNOTATION_KEYS.join(', ')}.`,
432
+ }));
433
+ /**
434
+ * EXT-70 §4.7/§9 — one server's entry under `approvals.mcp.servers`, and the shape `defaults`
435
+ * takes. **Strict**: an unrecognized key is an error, so a hint list misspelt as a whole key
436
+ * (`trustAnnotation`, `trust`) fails loudly instead of silently trusting nothing while reading as
437
+ * though it trusted something.
438
+ */
439
+ const mcpServerApprovalsSchema = z.strictObject({
440
+ trustAnnotations: trustAnnotationsSchema.optional(),
441
+ });
442
+ /**
443
+ * EXT-70 §4.7/§9 — the `approvals.mcp` block. `defaults` covers servers not named under `servers`;
444
+ * `servers` is keyed by the user's own `mcpServers` config key (§4.7.5) and is deliberately NOT
445
+ * checked against `mcpServers`, so policy may be written before the server it describes.
446
+ *
447
+ * Strict, and it stays strict: `expose` (§4.7.6) belongs to [[EXT-73]] and is not accepted here
448
+ * until that node adds it deliberately. A permissive block would accept `expose` today, do nothing
449
+ * with it, and leave a user believing their tools were filtered.
450
+ */
451
+ const mcpApprovalsSchema = z.strictObject({
452
+ defaults: mcpServerApprovalsSchema.optional(),
453
+ servers: z.record(z.string().min(1), mcpServerApprovalsSchema).optional(),
454
+ });
455
+ /**
456
+ * EXT-71 §3.1 — render an entry in the object form the user would write in a config file, with the
457
+ * fields in grammar order (`type`, `server`, `matcher`, `pattern`, then the optional bounds).
458
+ *
459
+ * The ONE place that spelling is produced, because it is shown in two very different moments that
460
+ * must agree: the load-time error that tells a user what to write instead of their bare string, and
461
+ * the escalation menu's *this is what will be stored* line (§6). A grant the menu describes one way
462
+ * and stores another is exactly the drift this design cannot afford.
463
+ */
464
+ export function renderApprovalEntryObject(entry) {
465
+ const fields = [['type', entry.type]];
466
+ if (entry.server !== undefined)
467
+ fields.push(['server', entry.server]);
468
+ fields.push(['matcher', entry.matcher], ['pattern', entry.pattern]);
469
+ if (entry.host !== undefined)
470
+ fields.push(['host', entry.host]);
471
+ if (entry.rate !== undefined)
472
+ fields.push(['rate', entry.rate]);
473
+ const rendered = fields.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`);
474
+ return `{ ${rendered.join(', ')} }`;
475
+ }
476
+ /**
477
+ * EXT-71 §2.3/§9.1 — render the object form of a bare string found in a rule list, so the
478
+ * migration error shows the user the entry they should have written *for their own string*
479
+ * rather than a generic example.
480
+ */
481
+ export function renderApprovalEntryForString(pattern) {
482
+ return renderApprovalEntryObject({ type: 'shell', matcher: 'exact', pattern });
483
+ }
484
+ /**
485
+ * CFG-27 — the `approvals` value: **either the rung name on its own, or an object when the extras
486
+ * are needed** (spec §9). There are no other approvals keys.
487
+ *
488
+ * ```jsonc
489
+ * { "approvals": "assisted" }
490
+ * ```
491
+ * ```jsonc
492
+ * { "approvals": {
493
+ * "mode": "assisted",
494
+ * "rater": "safety-rater", // identity profile the rater runs under
495
+ * "allow": [ { "type": "shell", "matcher": "exact", "pattern": "npm test" } ],
496
+ * "deny": [ { "type": "shell", "matcher": "glob", "pattern": "npm publish*" } ],
497
+ * "escalate": [ { "type": "shell", "matcher": "exact", "pattern": "terraform apply" } ]
498
+ * } }
499
+ * ```
500
+ *
501
+ * - **The scalar form is exactly sugar for `{ "mode": <value> }`** (§9.1). The union exists so the
502
+ * extras have a home when they are needed, not so there are two ways to say the same thing.
503
+ * - `rater` is a **bare identity-profile name**, not an object (strict resolution, GS2-62: a name
504
+ * that does not resolve is a hard config error, never a silent fallback).
505
+ * - `raterTimeoutMs` (EXT-66) is the wall-clock budget for ONE rating call, defaulting to
506
+ * `RATER_DEFAULT_TIMEOUT_MS` (30s) at the read site. **It exists because 30s is a hosted-model
507
+ * number and a local model is knowably slower**: measured 2026-07-31, `gemma4:12b` over Ollama
508
+ * answered a 23-case corpus in 6.0s–114.7s, and at the fixed limit 3 of 18 calls in one run and
509
+ * 9 of 17 in the next were cut off — so a local `auto` session degraded toward escalating
510
+ * everything, which is the opposite of what the rung is for. Deliberately a number the user owns
511
+ * rather than a provider→timeout table: a table is a guess about someone else's hardware, and
512
+ * the failure it causes is silent.
513
+ * - `allow`/`deny`/`escalate` are **read-only input**: merged with the runtime stores the
514
+ * escalation menu writes, and never written back to config. Every entry is the §3.1 object
515
+ * ({@link approvalEntrySchema}); a bare string in any of the three is a hard config error whose
516
+ * message shows the object form of that same string ({@link findApprovalsGrammarIssues}).
517
+ * - `escalate` is the third list (§3, §3.2): a match always asks the human, whatever the rung
518
+ * would have done. It takes the same entries as the other two.
519
+ * - `mcp` (EXT-70 §4.7) holds the per-server relationship — which hints are believed from which
520
+ * server — keyed by the user's own `mcpServers` config key, with `defaults` for servers not
521
+ * named. Absent or empty `trustAnnotations` believes nothing external, which is also the default.
522
+ * - The retired `strictness` / `allowlist` / `persistAllowlist` keys and the retired `auto` / `ask`
523
+ * mode values are hard migration errors naming their replacement — see `RETIRED_APPROVALS_KEYS` /
524
+ * `RETIRED_APPROVAL_MODES` in {@link findDeprecatedConfigIssues}. So is a NON-ARRAY `escalate`,
525
+ * which is the retired severity threshold rather than the new list.
526
+ *
527
+ * Defaults are applied at the READ site (`resolveApprovals` in `shell-policy.ts`), not in
528
+ * DEFAULT_CONFIG, so the effective-config snapshot never churns (à la GS2-34/GS2-63).
529
+ *
530
+ * NOTE: "judge" is reserved for the eval grader (`gth eval --judge <profile>`) — a different
531
+ * concept that keeps its name.
532
+ */
533
+ const approvalsSchema = z.union([
534
+ z.enum(APPROVAL_RUNG_VALUES),
68
535
  z.object({
69
- enabled: z.boolean().optional(),
70
- autoApproveLow: z.boolean().optional(),
71
- blockHigh: z.boolean().optional(),
72
- model: llmConfigSchema.optional(),
536
+ mode: z.enum(APPROVAL_RUNG_VALUES).optional(),
537
+ rater: z.string().optional(),
538
+ // EXT-71 §3.1 — the three rule lists. Same entry grammar in all three; the list decides only
539
+ // what a match DOES (§3: deny over escalate over allow).
540
+ allow: z.array(approvalEntrySchema).optional(),
541
+ deny: z.array(approvalEntrySchema).optional(),
542
+ escalate: z.array(approvalEntrySchema).optional(),
543
+ // EXT-66 — wall-clock budget (ms) for one rating call. See the note on the union above for
544
+ // why this is a user-owned number rather than a per-provider table.
545
+ // `.min(1)`, not `.positive()`: the latter emits `exclusiveMinimum`, a JSON-Schema draft
546
+ // keyword this repo avoids on principle (GS2-57 — Google GenAI rejects it outright in tool
547
+ // declarations). This schema is not a tool declaration, but one spelling everywhere is what
548
+ // stops the wrong one being copied into somewhere that is.
549
+ raterTimeoutMs: z.number().int().min(1).optional(),
550
+ // EXT-70 §4.7/§9 — the per-server MCP relationship, keyed by the user's own `mcpServers` key.
551
+ mcp: mcpApprovalsSchema.optional(),
73
552
  }),
74
553
  ]);
75
- const shellConfigSchema = z.union([
554
+ /**
555
+ * EXT-36 — the tool-loop guard (repeated identical `(tool, args)` / no-progress detector), the
556
+ * orthogonal sibling of GS2-36's error budget. A boolean-or-object union: `false` disables
557
+ * it entirely; `true`/absent is warn-on defaults; the object form is fine-grained. `warn` (default
558
+ * ON) injects a control-flow-free nudge at the threshold; `halt` (default OFF, opt-in) ends the run
559
+ * cleanly at the threshold; `threshold` is the number of consecutive identical calls that trip it.
560
+ * The warn-on default is applied at the read site, so this field is intentionally absent from
561
+ * DEFAULT_CONFIG.
562
+ */
563
+ const toolLoopGuardSchema = z.union([
76
564
  z.boolean(),
77
565
  z.object({
566
+ warn: z.boolean().optional(),
567
+ halt: z.boolean().optional(),
568
+ threshold: z.number().optional(),
569
+ }),
570
+ ]);
571
+ /**
572
+ * CFG-18 — the per-tool config object carried as a value in the widened `builtInTools` registry.
573
+ * One permissive shape covering every tool: `command` for the fixed dev-command tools
574
+ * (run_tests/run_lint/run_build/run_single_test), the EXT-12 execution knobs for
575
+ * `run_shell_command` (`timeout`/`maxOutputBytes`), `fileSet` for `gth_grep` (GS2-51), and
576
+ * `enabled` for a plain built-in tool.
577
+ *
578
+ * CFG-26 — the APPROVAL knobs that used to live here (`allowlist`, `persistAllowlist`, `judge`,
579
+ * `yolo`) moved to the top-level {@link approvalsSchema}. They were fields of the object shared by
580
+ * EVERY built-in tool, which is why a nonsensical `gth_grep: { yolo: true }` validated. Each is now
581
+ * a hard migration error — see `RETIRED_SHELL_TOOL_PAIRS` in {@link findDeprecatedConfigIssues}.
582
+ */
583
+ const builtInToolConfigSchema = z.object({
584
+ enabled: z.boolean().optional(),
585
+ command: z.string().optional(),
586
+ timeout: z.number().optional(),
587
+ maxOutputBytes: z.number().optional(),
588
+ // CFG-52 — `gth_gh_read_file`: ceiling on the DECODED file text the GitHub-API file-read tool
589
+ // returns (default 600 KiB). A file read, not captured command output, hence `maxBytes` rather
590
+ // than `maxOutputBytes`. See BuiltInToolConfig.
591
+ maxBytes: z.number().optional(),
592
+ // GS2-51 — `gth_grep`: which corpus to search. `gitignore` (default) respects .gitignore/.ignore
593
+ // and skips hidden dot-files; `all` scans everything but the noise dirs. See BuiltInToolConfig.
594
+ fileSet: z.enum(['gitignore', 'all']).optional(),
595
+ });
596
+ /**
597
+ * CFG-18 — the widened `builtInTools` setting: either the legacy `string[]` (each name enabled) or
598
+ * a registry keyed by tool name whose values enable (`true`), force-disable (`false`), or configure
599
+ * ({@link builtInToolConfigSchema}) each tool. Replaces `builtInTools: string[]` + per-command
600
+ * `devTools`.
601
+ */
602
+ const builtInToolsSchema = z.union([
603
+ z.array(z.string()),
604
+ z.record(z.string(), z.union([z.boolean(), builtInToolConfigSchema])),
605
+ ]);
606
+ /**
607
+ * GS2-43 — one prompt segment inside the {@link promptsSchema} object. Either a `string`
608
+ * (shorthand for `{ path }`) or a config object:
609
+ * - `path` — file to read for this segment (resolved like every prompt file: config dir /
610
+ * identity profile first, then relative to the project root).
611
+ * - `enabled: false` — drop the segment entirely (even its bundled default).
612
+ * - `mode` — `'replace'` (default): the file replaces the built-in segment content;
613
+ * `'append'`: the file content is appended after the built-in content.
614
+ */
615
+ const promptSegmentSchema = z.union([
616
+ z.string(),
617
+ z.object({
618
+ path: z.string().optional(),
78
619
  enabled: z.boolean().optional(),
79
- timeout: z.number().optional(),
80
- maxOutputBytes: z.number().optional(),
81
- allowlist: z.boolean().optional(),
82
- persistAllowlist: z.boolean().optional(),
83
- judge: shellJudgeSchema.optional(),
620
+ mode: z.enum(['replace', 'append']).optional(),
84
621
  }),
85
622
  ]);
86
- const devToolsConfigSchema = z.object({
87
- run_tests: z.string().optional(),
88
- run_lint: z.string().optional(),
89
- run_build: z.string().optional(),
90
- run_single_test: z.string().optional(),
91
- shell: shellConfigSchema.optional(),
92
- shellYolo: z.boolean().optional(),
623
+ /**
624
+ * GS2-43 — the unified `prompts` config object (CFG-18's flat-key→rich-object precedent).
625
+ * Replaces the removed flat `projectGuidelines` / `projectReviewInstructions` keys and makes
626
+ * ALL seven prompt segments retargetable through config (previously backstory/system/chat/
627
+ * code/exec were reachable only by placing a file in the config dir). Kept as a plain
628
+ * `z.object` of optional sibling keys so a future segment (e.g. GS2-44's `agents`) is a
629
+ * one-line addition with no collision risk.
630
+ */
631
+ const promptsSchema = z.object({
632
+ backstory: promptSegmentSchema.optional(),
633
+ guidelines: promptSegmentSchema.optional(),
634
+ system: promptSegmentSchema.optional(),
635
+ chat: promptSegmentSchema.optional(),
636
+ code: promptSegmentSchema.optional(),
637
+ exec: promptSegmentSchema.optional(),
638
+ review: promptSegmentSchema.optional(),
93
639
  });
94
640
  const prCommandSchema = z.object({
95
641
  contentSource: z.string().optional(),
96
642
  requirementSource: z.string().optional(),
97
643
  filesystem: filesystemSchema.optional(),
98
- builtInTools: z.array(z.string()).optional(),
644
+ builtInTools: builtInToolsSchema.optional(),
645
+ // CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
646
+ // `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
647
+ approvals: approvalsSchema.optional(),
99
648
  customTools: customToolsOrFalseSchema.optional(),
100
649
  allowedTools: z.array(z.string()).optional(),
101
650
  logWorkForReviewInSeconds: z.number().optional(),
@@ -106,7 +655,10 @@ const reviewCommandSchema = z.object({
106
655
  contentSource: z.string().optional(),
107
656
  requirementSource: z.string().optional(),
108
657
  filesystem: filesystemSchema.optional(),
109
- builtInTools: z.array(z.string()).optional(),
658
+ builtInTools: builtInToolsSchema.optional(),
659
+ // CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
660
+ // `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
661
+ approvals: approvalsSchema.optional(),
110
662
  customTools: customToolsOrFalseSchema.optional(),
111
663
  allowedTools: z.array(z.string()).optional(),
112
664
  rating: ratingConfigSchema.optional(),
@@ -114,38 +666,50 @@ const reviewCommandSchema = z.object({
114
666
  });
115
667
  const askCommandSchema = z.object({
116
668
  filesystem: filesystemSchema.optional(),
117
- builtInTools: z.array(z.string()).optional(),
669
+ builtInTools: builtInToolsSchema.optional(),
670
+ // CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
671
+ // `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
672
+ approvals: approvalsSchema.optional(),
118
673
  customTools: customToolsOrFalseSchema.optional(),
119
674
  allowedTools: z.array(z.string()).optional(),
120
- devTools: devToolsConfigSchema.optional(),
121
675
  binaryFormats: binaryFormatsSchema.optional(),
122
676
  });
123
677
  const chatCommandSchema = z.object({
124
678
  filesystem: filesystemSchema.optional(),
125
- builtInTools: z.array(z.string()).optional(),
679
+ builtInTools: builtInToolsSchema.optional(),
680
+ // CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
681
+ // `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
682
+ approvals: approvalsSchema.optional(),
126
683
  customTools: customToolsOrFalseSchema.optional(),
127
684
  allowedTools: z.array(z.string()).optional(),
128
685
  binaryFormats: binaryFormatsSchema.optional(),
129
686
  });
130
687
  const codeCommandSchema = z.object({
131
688
  filesystem: filesystemSchema.optional(),
132
- builtInTools: z.array(z.string()).optional(),
689
+ builtInTools: builtInToolsSchema.optional(),
690
+ // CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
691
+ // `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
692
+ approvals: approvalsSchema.optional(),
133
693
  customTools: customToolsOrFalseSchema.optional(),
134
694
  allowedTools: z.array(z.string()).optional(),
135
- devTools: devToolsConfigSchema.optional(),
136
695
  binaryFormats: binaryFormatsSchema.optional(),
137
696
  });
138
697
  const execCommandSchema = z.object({
139
698
  filesystem: filesystemSchema.optional(),
140
- builtInTools: z.array(z.string()).optional(),
699
+ builtInTools: builtInToolsSchema.optional(),
700
+ // CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
701
+ // `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
702
+ approvals: approvalsSchema.optional(),
141
703
  customTools: customToolsOrFalseSchema.optional(),
142
704
  allowedTools: z.array(z.string()).optional(),
143
- devTools: devToolsConfigSchema.optional(),
144
705
  binaryFormats: binaryFormatsSchema.optional(),
145
706
  });
146
707
  const apiCommandSchema = z.object({
147
708
  filesystem: filesystemSchema.optional(),
148
- builtInTools: z.array(z.string()).optional(),
709
+ builtInTools: builtInToolsSchema.optional(),
710
+ // CFG-27/§9.1 — per-command approvals. It overrides only the fields it names: `mode`,
711
+ // `rater`, `raterTimeoutMs` and `allow` replace the root's; `deny`/`escalate` concatenate.
712
+ approvals: approvalsSchema.optional(),
149
713
  port: z.number().optional(),
150
714
  cors: z
151
715
  .object({
@@ -155,6 +719,16 @@ const apiCommandSchema = z.object({
155
719
  })
156
720
  .optional(),
157
721
  });
722
+ /**
723
+ * GS2-33 — one profile-backed subagent: a `name` the model selects it by, an optional
724
+ * `description`, and the named config `profile` the CHILD resolves through the GS2-1 cascade when
725
+ * spawned. Not dispatched yet — see the `subagents` field below. See {@link SubagentProfileSpec}.
726
+ */
727
+ const subagentSpecSchema = z.object({
728
+ name: z.string(),
729
+ description: z.string().optional(),
730
+ profile: z.string(),
731
+ });
158
732
  const commandsSchema = z.object({
159
733
  pr: prCommandSchema.optional(),
160
734
  review: reviewCommandSchema.optional(),
@@ -164,6 +738,14 @@ const commandsSchema = z.object({
164
738
  exec: execCommandSchema.optional(),
165
739
  api: apiCommandSchema.optional(),
166
740
  });
741
+ /**
742
+ * The command names, derived from {@link commandsSchema}'s own shape so it can never drift
743
+ * from the schema. A command config must live under `commands.<cmd>`; the SAME name appearing
744
+ * at the config ROOT is the removed pre-2.0 top-level shape and is hard-rejected by
745
+ * {@link findDeprecatedConfigIssues}. (None of these are valid top-level keys, so there is no
746
+ * collision with {@link KNOWN_TOP_LEVEL_KEYS}.)
747
+ */
748
+ const COMMAND_KEYS = Object.keys(commandsSchema.shape);
167
749
  /**
168
750
  * Zod schema for the raw, on-disk Gaunt Sloth config. Loose at the top level so
169
751
  * unknown keys are preserved (warn-only via {@link findUnknownTopLevelKeys}).
@@ -172,12 +754,20 @@ export const rawGthConfigSchema = z.looseObject({
172
754
  // Allow a JSON Schema reference for editor support; never read at runtime.
173
755
  $schema: z.string().optional(),
174
756
  llm: llmConfigSchema.optional(),
175
- // Selects the agent backend. `deep` (the default when omitted) uses the deepagents
176
- // runtime; `lean` uses the plain LangChain agent (no `/large_tool_results` offload).
177
- // Honored at the AG-UI/api entry; the ACP server is deep-only and rejects `lean`.
757
+ // GS2-41 profile composition. A NAMED profile config may declare `extends: "<base-profile>"` to
758
+ // inherit another profile's config: the base profile resolves FIRST, then this profile's own
759
+ // fields merge on top (last-wins, same GS2-1 deep-merge as the config layers; a base may itself
760
+ // extend another, with a cycle guard). Consumed during load — never appears in the resolved
761
+ // config. See `resolveConfigExtends` in loader.ts.
762
+ extends: z.string().optional(),
763
+ // Selects the agent backend. `lean` — the plain LangChain agent with gsloth's full toolset — is
764
+ // the only one, and the default when omitted, so the key exists to let a config say so
765
+ // explicitly. The retired `deep` value is rejected with a migration message (EXT-114); see
766
+ // RETIRED_AGENT_BACKENDS. Kept as an enum rather than dropped so a future backend has a name to
767
+ // be selected by, and so a typo stays an error instead of an ignored unknown key.
178
768
  agent: z
179
769
  .object({
180
- backend: z.enum(['deep', 'lean']).optional(),
770
+ backend: z.enum(['lean']).optional(),
181
771
  })
182
772
  .optional(),
183
773
  // GS2-7 (B20) — local, opt-in session history. DEFAULT OFF: absent or `enabled: false` means
@@ -200,7 +790,8 @@ export const rawGthConfigSchema = z.looseObject({
200
790
  requirementSource: z.string().optional(),
201
791
  contentSourceConfig: z.record(z.string(), z.unknown()).optional(),
202
792
  requirementSourceConfig: z.record(z.string(), z.unknown()).optional(),
203
- projectGuidelines: z.string().optional(),
793
+ // GS2-43 — the unified prompt-segment config; replaces projectGuidelines/projectReviewInstructions.
794
+ prompts: promptsSchema.optional(),
204
795
  identityProfile: z.string().optional(),
205
796
  includeCurrentDateAfterGuidelines: z.boolean().optional(),
206
797
  organization: z
@@ -210,10 +801,15 @@ export const rawGthConfigSchema = z.looseObject({
210
801
  timezone: z.string().optional(),
211
802
  })
212
803
  .optional(),
213
- projectReviewInstructions: z.string().optional(),
214
804
  noDefaultPrompts: z.boolean().optional(),
215
805
  filesystem: filesystemSchema.optional(),
216
- builtInTools: z.array(z.string()).optional(),
806
+ builtInTools: builtInToolsSchema.optional(),
807
+ // CFG-27 — the approvals ladder: a rung name, or an object carrying the rater profile and the
808
+ // declared allow/deny lists. Settable at the root or per command
809
+ // (`commands.<command>.approvals`, which per §9.1 overrides only the fields it names — the
810
+ // restrictive lists concatenate across scopes, `allow` replaces). Absent = `assisted`
811
+ // (`resolveApprovals` in shell-policy.ts).
812
+ approvals: approvalsSchema.optional(),
217
813
  // Live tool instances / toolkits in JS configs — kept permissive.
218
814
  tools: z.array(z.unknown()).optional(),
219
815
  allowedTools: z.array(z.string()).optional(),
@@ -222,14 +818,34 @@ export const rawGthConfigSchema = z.looseObject({
222
818
  streamOutput: z.boolean().optional(),
223
819
  writeOutputToFile: z.union([z.boolean(), z.string()]).optional(),
224
820
  writeBinaryOutputsToFile: z.boolean().optional(),
821
+ // CFG-30 — rung 3 of the colour ladder (`config/colour.ts`), NOT the final say: `FORCE_COLOR`
822
+ // and `NO_COLOR` outrank it, and when it is absent colour auto-detects from stdout's TTY status.
823
+ // MUST stay `.optional()` — absence is what distinguishes "the user chose true" from the `true`
824
+ // in `defaults.ts`, and the ladder collapses without that. User docs: docs/configuration/output.md
225
825
  useColour: z.boolean().optional(),
826
+ // TUI-C37 — rung 2 of the mouse ladder (`config/mouse.ts`). MUST stay `.optional()` for the same
827
+ // reason as `useColour`: absence is what tells "the user chose true" from the `true` in
828
+ // `defaults.ts`, and rung 2 collapses into rung 4 without it.
829
+ useMouse: z.boolean().optional(),
830
+ // CFG-37 — persistent surface preference for the `chat`/`code` sessions: `true` asks for the Ink
831
+ // TUI, `false` for the plain readline session. MUST stay `.optional()` for the same reason as
832
+ // `useColour`/`useMouse`: absence is what distinguishes "the user chose readline" from "nobody
833
+ // said", and the auto-detect rung collapses without it. Ranked BELOW the `--tui`/`--no-tui` flags
834
+ // and the `GTH_NO_TUI` escape hatch, and below the capability gates (no TTY, `TERM=dumb`, `ink`
835
+ // unavailable) which are checks rather than preferences — so `true` degrades to readline instead
836
+ // of forcing a crash. The decision itself is `shouldUseTui` in the app package.
837
+ // User docs: docs/guides/interactive-sessions.md
838
+ tui: z.boolean().optional(),
226
839
  streamSessionInferenceLog: z.boolean().optional(),
227
840
  canInterruptInferenceWithEsc: z.boolean().optional(),
228
841
  debugLog: z.boolean().optional(),
229
842
  recursionLimit: z.number().optional(),
230
843
  consoleLevel: z.union([z.string(), z.number()]).optional(),
231
844
  customTools: customToolsConfigSchema.optional(),
232
- mcpServers: z.record(z.string(), z.unknown()).optional(),
845
+ // EXT-78 — keyed by the server's own name, whose two refused spellings ride on the key schema so
846
+ // the emitted JSON Schema states them too ({@link mcpServerNameSchema}).
847
+ mcpServers: z.record(mcpServerNameSchema, z.unknown()).optional(),
848
+ tls: tlsSchema.optional(),
233
849
  a2aAgents: z.record(z.string(), z.unknown()).optional(),
234
850
  builtInToolsConfig: z.record(z.string(), z.unknown()).optional(),
235
851
  aiignore: z
@@ -239,79 +855,697 @@ export const rawGthConfigSchema = z.looseObject({
239
855
  })
240
856
  .optional(),
241
857
  commands: commandsSchema.optional(),
858
+ // EXT-117 — the ACP (editor) surface. `mode` names the EXISTING command an ACP session resolves
859
+ // under; absent = `code`, so an editor gets an agent that can do the job rather than a read-only
860
+ // one. `chat` opts back into the read-only posture. Root-level rather than a `commands.acp`
861
+ // block on purpose — see `resolveAcpSessionCommand` in the agent package. Defaulted at the read
862
+ // site, not in DEFAULT_CONFIG, so the effective-config snapshot never churns and a user's `acp`
863
+ // object never has to survive a nested merge.
864
+ acp: z
865
+ .object({
866
+ mode: z.enum(ACP_SESSION_MODES).optional(),
867
+ })
868
+ .optional(),
869
+ // GS2-35/EXT-83 — identity used in the `Co-Authored-By` trailer of agent-authored git commits.
870
+ // Optional; when omitted the agent is instructed to co-author as the Gaunt Sloth account, whose
871
+ // default NAME carries the resolved active model — `Gaunt Sloth (provider:model)`, falling back
872
+ // to the bare `Gaunt Sloth` when no model resolves or `injectModelContext` is false — at the
873
+ // constant address `code@gauntsloth.app`. A configured name is emitted verbatim. Either field may
874
+ // be set alone; the other falls back to its default (see `appendCommitCoAuthorNote`).
875
+ commit: z
876
+ .object({
877
+ coAuthor: z
878
+ .object({
879
+ name: z.string().optional(),
880
+ email: z.string().optional(),
881
+ })
882
+ .optional(),
883
+ })
884
+ .optional(),
242
885
  modelDisplayName: z.string().optional(),
886
+ // GS2-34/EXT-83 — inject the resolved active `provider:model` identity into the system prompt
887
+ // (default ON; set `false` to opt out for reproducible / model-agnostic runs). It governs the
888
+ // identity everywhere in the prompt: the identity line in every mode, AND the model name that
889
+ // decorates the default git commit co-author trailer in `code` mode. Defaulted at the read site,
890
+ // so it is intentionally absent from DEFAULT_CONFIG (no effective-config snapshot churn).
891
+ injectModelContext: z.boolean().optional(),
892
+ // GS2-47 — secret-redaction for `/debug-dump` artifacts. `redact` DEFAULTS ON (omitted = redact);
893
+ // set `false` to write a raw, unredacted archive (the command then shows a loud secrets warning).
894
+ // Defaulted at the read site (`!== false`), not in DEFAULT_CONFIG, to avoid churning the
895
+ // effective-config snapshot (à la GS2-34 injectModelContext).
896
+ debugDump: z
897
+ .object({
898
+ redact: z.boolean().optional(),
899
+ })
900
+ .optional(),
243
901
  allowDirs: z.array(z.string()).optional(),
244
902
  askWriteMode: z.boolean().optional(),
903
+ // GS2-93 — output surface controls. `header` is one of {@link OUTPUT_HEADER_RUNGS} and DEFAULTS
904
+ // to `debug` (omitted = the full preamble, unchanged). It grades what a NON-TUI text run
905
+ // (`--no-tui`, `ask`, `exec`, `eval`, `pr`, `review`, piped/CI) opens with: `debug` the full
906
+ // Workdir/Model/Tools/Middleware block plus the `Press Escape or Q to interrupt` hint, `compact`
907
+ // one attribution line instead, `none` nothing. The interactive TUI ignores it and always shows
908
+ // the full header. Only that opening is graded — never model/tool output, errors, or
909
+ // config-validation warnings. Defaulted at the read site, not in DEFAULT_CONFIG, to avoid
910
+ // churning the effective-config snapshot (à la GS2-34 injectModelContext).
911
+ output: z
912
+ .object({
913
+ header: z.enum(OUTPUT_HEADER_RUNGS, { error: describeBadHeaderRung }).optional(),
914
+ })
915
+ .optional(),
916
+ // EXT-36 — tool-loop guard (repeated identical (tool, args) / no-progress detector), the sibling
917
+ // of GS2-36's error budget. `false` disables; `true`/absent = warn-on defaults; object =
918
+ // fine-grained ({ warn, halt, threshold }). WARN (default ON) injects a control-flow-free nudge;
919
+ // HALT (default OFF, opt-in) ends the run cleanly at the threshold. Defaulted at the read site
920
+ // (warn on), not in DEFAULT_CONFIG, so the effective-config snapshot never churns.
921
+ toolLoopGuard: toolLoopGuardSchema.optional(),
922
+ // BATCH-19 — custom `gth eval` reporters. Maps a reporter NAME (as selected with
923
+ // `--reporter <name>`) to a MODULE PATH (relative to the project dir) whose default export is an
924
+ // `EvalReporterFactory` (`() => EvalReporter`). Registered through the same seam the bundled
925
+ // reporters use; a name here overrides a built-in of the same name.
926
+ reporters: z.record(z.string(), z.string()).optional(),
927
+ // GS2-33 — profile-backed subagents. Each entry names a subagent and the named config profile the
928
+ // CHILD resolves when the parent spawns it, so a subagent can run under a different
929
+ // model/tools/prompt than the parent. See {@link subagentSpecSchema}.
930
+ //
931
+ // No backend dispatches subagents today — the `task` tool went with the deepagents runtime
932
+ // (EXT-114) and the lean primitive lands in GS2-25. The key is KEPT rather than retired so a
933
+ // config that already declares subagents keeps validating, and a run that declares them says so
934
+ // out loud (see `warnIfSubagentsCannotBeHonored`) instead of dropping them in silence.
935
+ subagents: z.array(subagentSpecSchema).optional(),
245
936
  });
246
937
  /**
247
938
  * The set of known top-level config keys, derived from the schema shape so it can
248
939
  * never drift from {@link rawGthConfigSchema}. Includes `$schema`.
249
940
  */
250
941
  export const KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(rawGthConfigSchema.shape));
942
+ /**
943
+ * True when a raw config value is a plain (non-null, non-array) object, so the key scans
944
+ * ({@link findDeprecatedConfigIssues}, {@link findUnknownTopLevelKeys}) are safe to run. A
945
+ * `null`/array/primitive config (e.g. a JSON file that is just `null`, or a module
946
+ * `configure()` returning null) must NOT reach those scans — they'd throw on
947
+ * `hasOwnProperty`/`Object.keys`; the entry points instead hand it straight to
948
+ * `rawGthConfigSchema.safeParse`, which reports a clean "expected object" error.
949
+ */
950
+ export function isRecordConfig(value) {
951
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
952
+ }
251
953
  /**
252
954
  * Return the top-level keys present in `raw` that are not part of the known config
253
- * surface. Deprecated aliases should be pre-mapped away first (see
254
- * {@link preMapDeprecatedConfigNames}) so they do not show up here.
955
+ * surface. KNOWN deprecated names are rejected earlier by {@link findDeprecatedConfigIssues}
956
+ * (the entry points short-circuit on those), so a key that reaches here is a genuinely
957
+ * unknown key (a likely typo) that only warrants a warning.
255
958
  */
256
959
  export function findUnknownTopLevelKeys(raw) {
257
960
  return Object.keys(raw).filter((key) => !KNOWN_TOP_LEVEL_KEYS.has(key));
258
961
  }
962
+ /**
963
+ * Render ` - <path>: <message>` lines (one per issue), with `(root)` for an empty
964
+ * path. Shared by {@link formatConfigValidationError} (Zod issues) and
965
+ * {@link formatDeprecatedConfigIssues} (deprecated-shape issues) so both surfaces read
966
+ * identically.
967
+ */
968
+ function formatIssueLines(issues) {
969
+ return issues.map((issue) => ` - ${issue.path || '(root)'}: ${issue.message}`).join('\n');
970
+ }
259
971
  /**
260
972
  * Render a friendly, path-scoped validation message from a Zod error. Each issue
261
973
  * becomes a line ` - <path>: <message>`, with `(root)` for top-level issues.
262
974
  */
263
975
  export function formatConfigValidationError(error) {
264
- return error.issues
265
- .map((issue) => {
266
- const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
267
- return ` - ${path}: ${issue.message}`;
268
- })
269
- .join('\n');
976
+ return formatIssueLines(error.issues.map((issue) => ({
977
+ path: issue.path.length > 0 ? issue.path.join('.') : '',
978
+ message: issue.message,
979
+ })));
270
980
  }
271
- /** Deprecated → canonical key pairs at the config root. */
981
+ /** Deprecated → canonical key pairs at the config root (SSOT for the rejecter). */
272
982
  const DEPRECATED_ROOT_PAIRS = [
273
983
  ['contentProvider', 'contentSource'],
274
984
  ['requirementsProvider', 'requirementSource'],
275
985
  ['contentProviderConfig', 'contentSourceConfig'],
276
986
  ['requirementsProviderConfig', 'requirementSourceConfig'],
987
+ // GS2-43 — the flat prompt-path keys were folded into the `prompts` object.
988
+ ['projectGuidelines', 'prompts.guidelines'],
989
+ ['projectReviewInstructions', 'prompts.review'],
277
990
  ];
278
- /** Deprecated → canonical key pairs inside a `commands.<name>` block. */
991
+ /** Deprecated → canonical key pairs inside a `commands.<name>` block (SSOT for the rejecter). */
279
992
  const DEPRECATED_COMMAND_PAIRS = [
280
993
  ['contentProvider', 'contentSource'],
281
994
  ['requirementsProvider', 'requirementSource'],
282
995
  ];
283
- function remapDeprecated(obj, pairs, scope, warnings) {
284
- for (const [deprecated, canonical] of pairs) {
285
- if (Object.prototype.hasOwnProperty.call(obj, deprecated)) {
286
- warnings.push(`Config property "${deprecated}"${scope} is deprecated. Use "${canonical}" instead.`);
287
- // Canonical wins when both are present; otherwise adopt the deprecated value.
288
- if (obj[canonical] === undefined) {
289
- obj[canonical] = obj[deprecated];
996
+ /**
997
+ * CFG-18 removed per-command keys with no 1:1 rename: the config they carried was FOLDED into
998
+ * another key. `[removed, replacement]`. `devTools` (the split dev/shell config) is now configured
999
+ * under the unified `builtInTools` registry. Rejected here specifically because `devTools` lived
1000
+ * under a per-command `z.object`, which SILENTLY STRIPS unknown keys — so without this pre-parse
1001
+ * reject an old `commands.<cmd>.devTools` block would vanish with no error (worse than today).
1002
+ */
1003
+ const REMOVED_COMMAND_KEYS = [
1004
+ ['devTools', 'builtInTools'],
1005
+ ];
1006
+ /**
1007
+ * CFG-26 — approval knobs retired from the `builtInTools.run_shell_command` entry and moved to the
1008
+ * top-level `approvals` block. `[retired, "how to say it now"]`. Rejected pre-parse for the same
1009
+ * reason as {@link REMOVED_COMMAND_KEYS}: these knobs lived under `builtInToolConfigSchema`, a
1010
+ * plain `z.object`, which SILENTLY STRIPS unknown keys — so once the field is gone an old config
1011
+ * would parse clean and run with its approval posture quietly ignored, the worst possible failure
1012
+ * for a safety gate.
1013
+ *
1014
+ * `judge.autoApproveLow` / `judge.blockHigh` have no 1:1 successor (the rung replaced the
1015
+ * `low/medium/high` × `destructive` conjunction), so `judge`'s message points at `approvals.mode`
1016
+ * and names the outcome scale that took the tiers' place.
1017
+ */
1018
+ const RETIRED_SHELL_TOOL_PAIRS = [
1019
+ ['yolo', '"approvals": "bypass"'],
1020
+ [
1021
+ 'judge',
1022
+ '"approvals": "assisted" (or "auto"), optionally with "approvals.rater" naming an ' +
1023
+ 'identity profile — the low/medium/high tiers became the ' +
1024
+ 'safe/destructive/catastrophic/attack outcomes, and autoApproveLow/blockHigh are replaced ' +
1025
+ 'by the mode you choose',
1026
+ ],
1027
+ ['allowlist', '"approvals.allow" (a declared list of rule entries)'],
1028
+ [
1029
+ 'persistAllowlist',
1030
+ 'nothing — persistence is a per-decision choice at the approval prompt (approve forgets, ' +
1031
+ 'always approve persists)',
1032
+ ],
1033
+ ];
1034
+ /** The `builtInTools` entry the retired approval knobs used to hang off. */
1035
+ const SHELL_TOOL_REGISTRY_KEY = 'run_shell_command';
1036
+ /**
1037
+ * CFG-27 — keys retired from the `approvals` object itself when the four-tier lattice became one
1038
+ * ordered ladder of five rungs. `[retired, "how to say it now"]`.
1039
+ *
1040
+ * Rejected PRE-PARSE rather than left to the union: `approvalsSchema`'s object arm is a `z.object`,
1041
+ * which silently strips unknown keys, so an `approvals: { mode: "assisted", strictness: "strict" }`
1042
+ * would otherwise run with its declared posture quietly ignored — the worst possible failure for a
1043
+ * safety gate, and exactly what CFG-26 fixed for the per-tool knobs.
1044
+ *
1045
+ * `strictness` is **deleted, not remapped**: there are no severity thresholds and no independent
1046
+ * rater switch, so the message points at the rung that expresses the intent.
1047
+ *
1048
+ * The retired severity threshold `escalate` is NOT in this table, because EXT-71 gave the name back
1049
+ * as the third rule list (§3). A non-array `escalate` — the shape the threshold had — is still
1050
+ * caught, with the same message, by {@link RETIRED_ESCALATE_THRESHOLD_MESSAGE}.
1051
+ */
1052
+ const RETIRED_APPROVALS_KEYS = [
1053
+ [
1054
+ 'strictness',
1055
+ 'nothing — there are no strictness levels any more. Choose a mode instead: ' +
1056
+ '"manual"/"write" never rate, "assisted" and "auto" escalate anything not rated safe, ' +
1057
+ '"bypass" rates nothing',
1058
+ ],
1059
+ ['allowlist', '"approvals.allow" (a declared list of rule entries)'],
1060
+ [
1061
+ 'persistAllowlist',
1062
+ 'nothing — persistence is a per-decision choice at the approval prompt (approve forgets, ' +
1063
+ 'always approve persists)',
1064
+ ],
1065
+ ];
1066
+ /**
1067
+ * EXT-71 — `approvals.escalate` used to be a SEVERITY THRESHOLD (a string), and is now the third
1068
+ * rule LIST (an array of §3.1 entries). Only the old shape is an error, so the name could be
1069
+ * reused without stranding anyone: a non-array value gets the message that names the rung which
1070
+ * expresses the old intent, instead of a bare "expected array, received string".
1071
+ */
1072
+ const RETIRED_ESCALATE_THRESHOLD_MESSAGE = 'is now the third rule LIST (an array of {type, matcher, pattern} entries that always ask the ' +
1073
+ 'human), not a severity threshold. There is no escalate threshold any more: at "assisted" and ' +
1074
+ '"auto" everything the auto-rater does not rate safe comes to you.';
1075
+ /**
1076
+ * Retired `approvals.mode` VALUES → the rung that replaced them.
1077
+ *
1078
+ * Caught here rather than by the enum so the error NAMES the rung instead of listing five
1079
+ * identifiers and leaving the user to guess which one preserves their intent.
1080
+ *
1081
+ * **No rung is raised without the user choosing it.** That is the property to preserve when adding
1082
+ * an entry: a retired name remapped to something MORE permissive would raise a user's autonomy
1083
+ * setting on their behalf, at the moment they are least watching — the config already worked, so
1084
+ * nothing prompts them to re-read it.
1085
+ *
1086
+ * `manual`/`assisted`/`auto` are pure renames of `read-only`/`auto-safe`/`full-auto` and carry
1087
+ * identical behaviour, so they preserve it trivially. `ask` is the one entry that does not, and the
1088
+ * exception is worth stating rather than glossing: `write` auto-grants working-folder file edits
1089
+ * that `ask` prompted for, which is strictly a widening. It is safe here only because nothing is
1090
+ * remapped silently — every entry is a hard validation ERROR, and this one offers `write` or
1091
+ * `manual` as a labelled choice with the difference spelled out, so the user picks. An entry that
1092
+ * widened while merely warning, or while coercing, would break the property.
1093
+ *
1094
+ * `write` and `bypass` are deliberately ABSENT — they are live rung names, not retired ones.
1095
+ *
1096
+ * **`auto` is deliberately absent too, and that is a reversal.** It named the pre-2.0
1097
+ * rater-mediated mode and used to error here; it is now the canonical name of the most permissive
1098
+ * rated rung. Reviving the token is not a back-compat concern (Andrew, 2026-08-07): the value was
1099
+ * born during 2.0 alpha work, so no released configuration carries it with the old meaning. This
1100
+ * entry and the rename must land together — an `auto` left in this table makes the newly canonical
1101
+ * name a hard validation error on arrival.
1102
+ */
1103
+ const RETIRED_APPROVAL_MODES = [
1104
+ ['read-only', '"manual" (the same mode, renamed)'],
1105
+ ['auto-safe', '"assisted" (the same mode, renamed)'],
1106
+ ['full-auto', '"auto" (the same mode, renamed)'],
1107
+ [
1108
+ 'ask',
1109
+ '"write" (Gaunt Sloth edits files freely and asks about everything else) or "manual" ' +
1110
+ '(it asks before writing too)',
1111
+ ],
1112
+ ];
1113
+ /**
1114
+ * EXT-114 — retired `agent.backend` VALUES → what to say instead. `[retired, replacement]`.
1115
+ *
1116
+ * Caught pre-parse for the same reason as {@link RETIRED_APPROVAL_MODES}: the enum's own message
1117
+ * would list the surviving identifier and leave the user to guess whether their setting still
1118
+ * means anything. It does not — `deep` named a runtime that no longer exists, so the value is a
1119
+ * HARD error rather than a coercion to `lean`. Coercing would silently run a different agent than
1120
+ * the config asked for, which is precisely the substitution a config file exists to prevent, and
1121
+ * the run that mattered (subagents, summarization, large-tool-result offload) would come back
1122
+ * looking like a capability regression with nothing to point at.
1123
+ */
1124
+ const RETIRED_AGENT_BACKENDS = [
1125
+ [
1126
+ 'deep',
1127
+ '"lean" — the only backend there is. It carries the same toolset, system prompt, ' +
1128
+ 'approval gate and checklist; what leaves with "deep" is the deepagents-only extras ' +
1129
+ '(the subagent "task" tool, summarization and large-tool-result offload)',
1130
+ ],
1131
+ ];
1132
+ /**
1133
+ * Pointer to the migration path, appended to every deprecated-shape error so the user
1134
+ * always learns HOW to fix it, not just that it broke. Doc link only, per DOC-STYLE
1135
+ * rule 9 (user-visible doc references are absolute GitHub URLs).
1136
+ * GS2-5 (B15) will ship `gth config migrate` / `gth doctor --fix`; restore the command
1137
+ * reference here when that tooling lands.
1138
+ */
1139
+ const MIGRATION_HINT = 'See the 2.0 migration notes: https://github.com/pukeko-robotics/gaunt-sloth/blob/main/docs/MIGRATION.md';
1140
+ /**
1141
+ * CFG-26 — scan one `builtInTools` value (root or per-command) for the retired
1142
+ * `run_shell_command` approval knobs, pushing one issue per occurrence. Only the OBJECT registry
1143
+ * form can carry them (the legacy `string[]` form is names-only), so an array/non-object value is
1144
+ * a no-op. `pathPrefix` is `builtInTools` at the root and `commands.<name>.builtInTools` per
1145
+ * command, so the reported path points exactly at the offending key.
1146
+ */
1147
+ function collectRetiredShellToolIssues(builtInTools, pathPrefix, issues) {
1148
+ if (!builtInTools || typeof builtInTools !== 'object' || Array.isArray(builtInTools))
1149
+ return;
1150
+ const shellEntry = builtInTools[SHELL_TOOL_REGISTRY_KEY];
1151
+ if (!shellEntry || typeof shellEntry !== 'object' || Array.isArray(shellEntry))
1152
+ return;
1153
+ for (const [retired, replacement] of RETIRED_SHELL_TOOL_PAIRS) {
1154
+ if (Object.prototype.hasOwnProperty.call(shellEntry, retired)) {
1155
+ issues.push({
1156
+ path: `${pathPrefix}.${SHELL_TOOL_REGISTRY_KEY}.${retired}`,
1157
+ message: `Config property "${retired}" in ${pathPrefix}.${SHELL_TOOL_REGISTRY_KEY} is no longer ` +
1158
+ `supported in 2.0. Use ${replacement} instead. ${MIGRATION_HINT}`,
1159
+ });
1160
+ }
1161
+ }
1162
+ }
1163
+ /**
1164
+ * CFG-27 — scan one `approvals` value (root or per-command) for the keys and `mode` values the
1165
+ * five-rung ladder retired, pushing one issue per occurrence. The scalar form carries no keys, so
1166
+ * only the object form can offend — except for a retired `mode` VALUE, which the scalar form can
1167
+ * carry directly (`"approvals": "auto"`), so both shapes are checked for that.
1168
+ *
1169
+ * `pathPrefix` is `approvals` at the root and `commands.<name>.approvals` per command, so the
1170
+ * reported path points exactly at the offending key.
1171
+ */
1172
+ function collectRetiredApprovalsIssues(approvals, pathPrefix, issues) {
1173
+ if (approvals === undefined || approvals === null)
1174
+ return;
1175
+ // The scalar sugar form: `"approvals": "auto"`. Only a retired VALUE is an issue here; an
1176
+ // unknown string is left to the enum, which lists the five valid rungs.
1177
+ if (typeof approvals === 'string') {
1178
+ for (const [retired, replacement] of RETIRED_APPROVAL_MODES) {
1179
+ if (approvals === retired) {
1180
+ issues.push({
1181
+ path: pathPrefix,
1182
+ message: `Approval mode "${retired}" is no longer supported: approvals is now one ordered ` +
1183
+ `ladder of five modes (${APPROVAL_RUNG_LIST}). ` +
1184
+ `Use ${replacement} instead. ${MIGRATION_HINT}`,
1185
+ });
290
1186
  }
291
- delete obj[deprecated];
1187
+ }
1188
+ return;
1189
+ }
1190
+ if (typeof approvals !== 'object' || Array.isArray(approvals))
1191
+ return;
1192
+ const block = approvals;
1193
+ for (const [retired, replacement] of RETIRED_APPROVALS_KEYS) {
1194
+ if (Object.prototype.hasOwnProperty.call(block, retired)) {
1195
+ issues.push({
1196
+ path: `${pathPrefix}.${retired}`,
1197
+ message: `Config property "${retired}" in ${pathPrefix} is no longer supported: approvals is now ` +
1198
+ `one ordered ladder of five modes (${APPROVAL_RUNG_LIST}), ` +
1199
+ `and each mode fully determines behaviour. Use ${replacement}. ${MIGRATION_HINT}`,
1200
+ });
1201
+ }
1202
+ }
1203
+ // EXT-71 — `escalate` reused for the third rule list; only the retired THRESHOLD shape errors.
1204
+ if (block.escalate !== undefined && !Array.isArray(block.escalate)) {
1205
+ issues.push({
1206
+ path: `${pathPrefix}.escalate`,
1207
+ message: `Config property "escalate" in ${pathPrefix} ${RETIRED_ESCALATE_THRESHOLD_MESSAGE} ` +
1208
+ MIGRATION_HINT,
1209
+ });
1210
+ }
1211
+ // `rater` flattened from an object to a bare identity-profile name.
1212
+ const rater = block.rater;
1213
+ if (rater !== undefined && typeof rater !== 'string') {
1214
+ issues.push({
1215
+ path: `${pathPrefix}.rater`,
1216
+ message: `Config property "rater" in ${pathPrefix} is now a bare identity-profile name, not an ` +
1217
+ `object or a boolean (e.g. "rater": "safety-rater"). Whether the rater runs at all is ` +
1218
+ `decided by the mode: "assisted" and "auto" rate, the other three never do. ` +
1219
+ MIGRATION_HINT,
1220
+ });
1221
+ }
1222
+ for (const [retired, replacement] of RETIRED_APPROVAL_MODES) {
1223
+ if (block.mode === retired) {
1224
+ issues.push({
1225
+ path: `${pathPrefix}.mode`,
1226
+ message: `Approval mode "${retired}" is no longer supported: approvals is now one ordered ladder ` +
1227
+ `of five modes (${APPROVAL_RUNG_LIST}). Use ${replacement} ` +
1228
+ `instead. ${MIGRATION_HINT}`,
1229
+ });
292
1230
  }
293
1231
  }
294
1232
  }
295
1233
  /**
296
- * B3one-way pre-map of deprecated config key names to their canonical names, at
297
- * the config root AND per-command. Mutates `raw` in place (it is always a freshly
298
- * loaded config layer) and returns it alongside a list of deprecation warnings the
299
- * caller should emit via `displayWarning`. Canonical names win when both are
300
- * present; the deprecated key is always removed so it does not later surface as an
301
- * "unknown top-level key".
1234
+ * EXT-114scan the `agent` value for a retired `backend` name ({@link RETIRED_AGENT_BACKENDS}),
1235
+ * pushing one issue per occurrence so the error names the surviving backend and what the retired
1236
+ * one took with it.
302
1237
  */
303
- export function preMapDeprecatedConfigNames(raw) {
304
- const warnings = [];
305
- remapDeprecated(raw, DEPRECATED_ROOT_PAIRS, '', warnings);
1238
+ function collectRetiredAgentBackendIssues(agent, pathPrefix, issues) {
1239
+ if (!agent || typeof agent !== 'object' || Array.isArray(agent))
1240
+ return;
1241
+ const backend = agent.backend;
1242
+ for (const [retired, replacement] of RETIRED_AGENT_BACKENDS) {
1243
+ if (backend === retired) {
1244
+ issues.push({
1245
+ path: `${pathPrefix}.backend`,
1246
+ message: `Agent backend "${retired}" is no longer supported: Gaunt Sloth ships one agent ` +
1247
+ `backend. Use ${replacement}. ${MIGRATION_HINT}`,
1248
+ });
1249
+ }
1250
+ }
1251
+ }
1252
+ /**
1253
+ * GS2-28 — detect the removed pre-2.0 config shapes on the RAW input (read-only; no
1254
+ * mutation), returning one {@link DeprecatedConfigIssue} per occurrence. A non-empty
1255
+ * result is a HARD validation failure: 2.0 dropped back-compat coercion, so an old shape
1256
+ * must error and point at the fix rather than be silently remapped or ignored.
1257
+ *
1258
+ * Detects:
1259
+ * - (A) a COMMAND name ({@link COMMAND_KEYS}) at the config ROOT — must move under `commands.<cmd>`;
1260
+ * - (C) a deprecated `*Provider*` name ({@link DEPRECATED_ROOT_PAIRS} at root,
1261
+ * {@link DEPRECATED_COMMAND_PAIRS} per command) — must use its `*Source*` replacement;
1262
+ * - (D, CFG-18) a removed per-command key folded into another ({@link REMOVED_COMMAND_KEYS}, e.g.
1263
+ * `commands.<cmd>.devTools` → configure under `builtInTools`);
1264
+ * - (E, CFG-26) a retired `run_shell_command` approval knob ({@link RETIRED_SHELL_TOOL_PAIRS}) at
1265
+ * EITHER `builtInTools.run_shell_command.*` or `commands.<cmd>.builtInTools.run_shell_command.*`
1266
+ * — each message names the `approvals.*` key that replaced it;
1267
+ * - (F, CFG-27) a retired `approvals` key or `mode` value ({@link RETIRED_APPROVALS_KEYS},
1268
+ * {@link RETIRED_APPROVAL_MODES}) at EITHER `approvals.*` or `commands.<cmd>.approvals.*` —
1269
+ * each message names the rung that replaced it;
1270
+ * - (G, EXT-114) a retired `agent.backend` value ({@link RETIRED_AGENT_BACKENDS}) — the message
1271
+ * names the surviving backend and what the retired one took with it.
1272
+ *
1273
+ * Runs on the raw input specifically so nested `commands.*.contentProvider` is still visible
1274
+ * (zod's per-command `z.object` would strip it before any schema-embedded check could fire).
1275
+ * A genuinely-unknown key is NOT flagged here (it stays a warn-only unknown key), preserving
1276
+ * the deliberate typo-tolerance.
1277
+ */
1278
+ export function findDeprecatedConfigIssues(raw) {
1279
+ const issues = [];
1280
+ // (A) Command name used as a top-level key — command configs must live under commands.<cmd>.
1281
+ for (const command of COMMAND_KEYS) {
1282
+ if (Object.prototype.hasOwnProperty.call(raw, command)) {
1283
+ issues.push({
1284
+ path: command,
1285
+ message: `Top-level command config "${command}" is no longer supported in 2.0. ` +
1286
+ `Move it under "commands.${command}". ${MIGRATION_HINT}`,
1287
+ });
1288
+ }
1289
+ }
1290
+ // (C) Deprecated *Provider* names at the config root.
1291
+ for (const [deprecated, canonical] of DEPRECATED_ROOT_PAIRS) {
1292
+ if (Object.prototype.hasOwnProperty.call(raw, deprecated)) {
1293
+ issues.push({
1294
+ path: deprecated,
1295
+ message: `Config property "${deprecated}" was renamed in 2.0. Use "${canonical}" instead. ` +
1296
+ MIGRATION_HINT,
1297
+ });
1298
+ }
1299
+ }
1300
+ // (E, CFG-26) Retired run_shell_command approval knobs at the ROOT builtInTools registry.
1301
+ collectRetiredShellToolIssues(raw.builtInTools, 'builtInTools', issues);
1302
+ // (F, CFG-27) Retired approvals keys / mode values in the ROOT approvals value.
1303
+ collectRetiredApprovalsIssues(raw.approvals, 'approvals', issues);
1304
+ // (G, EXT-114) Retired agent backend name. Root-only: `agent` is not a per-command key.
1305
+ collectRetiredAgentBackendIssues(raw.agent, 'agent', issues);
1306
+ // (C) Deprecated *Provider* names + (CFG-18) removed keys inside each commands.<name> block.
1307
+ const commands = raw.commands;
1308
+ if (commands && typeof commands === 'object' && !Array.isArray(commands)) {
1309
+ for (const [name, cmd] of Object.entries(commands)) {
1310
+ if (cmd && typeof cmd === 'object' && !Array.isArray(cmd)) {
1311
+ for (const [deprecated, canonical] of DEPRECATED_COMMAND_PAIRS) {
1312
+ if (Object.prototype.hasOwnProperty.call(cmd, deprecated)) {
1313
+ issues.push({
1314
+ path: `commands.${name}.${deprecated}`,
1315
+ message: `Config property "${deprecated}" in commands.${name} was renamed in 2.0. ` +
1316
+ `Use "${canonical}" instead. ${MIGRATION_HINT}`,
1317
+ });
1318
+ }
1319
+ }
1320
+ // (CFG-18) removed per-command keys folded into another key (e.g. devTools → builtInTools).
1321
+ for (const [removed, replacement] of REMOVED_COMMAND_KEYS) {
1322
+ if (Object.prototype.hasOwnProperty.call(cmd, removed)) {
1323
+ issues.push({
1324
+ path: `commands.${name}.${removed}`,
1325
+ message: `Config property "${removed}" in commands.${name} is no longer supported in 2.0. ` +
1326
+ `Configure tools under "${replacement}" instead. ${MIGRATION_HINT}`,
1327
+ });
1328
+ }
1329
+ }
1330
+ // (E, CFG-26) retired run_shell_command approval knobs in this command's registry.
1331
+ collectRetiredShellToolIssues(cmd.builtInTools, `commands.${name}.builtInTools`, issues);
1332
+ // (F, CFG-27) retired approvals keys / mode values in this command's approvals value.
1333
+ collectRetiredApprovalsIssues(cmd.approvals, `commands.${name}.approvals`, issues);
1334
+ }
1335
+ }
1336
+ }
1337
+ return issues;
1338
+ }
1339
+ /**
1340
+ * EXT-70 §4.7.5 — the MCP server name that cannot be written about. A server key is
1341
+ * `z.string().min(1)` both under `approvals.mcp.servers` (§9) and on an `mcpTool` entry's `server`
1342
+ * field (§3.1), so a server keyed with the empty string is one no approvals rule and no trust
1343
+ * relationship can ever refer to by name. Its tools resolve to the unattributable-server sentinel:
1344
+ * fail-closed, which is safe, but also silently un-configurable — the user would get a server whose
1345
+ * every call is gated with no way to say anything about it and no error explaining why. Refused at
1346
+ * load, for the same reason as {@link RESERVED_MCP_SERVER_NAME}: a name whose rules cannot be
1347
+ * expressed is worse than a rejected config.
1348
+ */
1349
+ const UNNAMEABLE_MCP_SERVER_NAME = '';
1350
+ /**
1351
+ * EXT-71 §3.1/§9.1 — validate every entry in one `approvals` value's three rule lists (root or per
1352
+ * command), pushing one issue per problem with a path that points at the exact entry and field.
1353
+ *
1354
+ * **Why the entries are checked HERE rather than left to the schema parse**, given that
1355
+ * `approvalsSchema` already carries {@link approvalEntrySchema}: the `approvals` value is a union
1356
+ * (the §9.1 scalar-or-object sugar), and zod reports a failing union as ONE issue at the union's
1357
+ * own path — `approvals: Invalid input` — with every arm's real diagnosis nested out of reach of
1358
+ * the formatter. That is exactly the wrong message for this grammar, where the whole requirement is
1359
+ * that a rejection names the offending field, key or pattern. Parsing each entry on its own gets
1360
+ * the precise issue back, and because this runs BEFORE the parse the precise message is the only
1361
+ * one the user sees. The schema keeps the entries too, so it stays the authority and the emitted
1362
+ * JSON Schema still describes them.
1363
+ *
1364
+ * A bare string is handled separately from the rest, because its message is the migration
1365
+ * affordance: it renders the entry for the string that was actually found rather than a generic
1366
+ * example, since what the user needs is the line they can paste back over the one they wrote.
1367
+ */
1368
+ function collectApprovalEntryIssues(approvals, pathPrefix, issues) {
1369
+ if (!approvals || typeof approvals !== 'object' || Array.isArray(approvals))
1370
+ return;
1371
+ const block = approvals;
1372
+ for (const listKey of APPROVAL_LIST_KEYS) {
1373
+ const list = block[listKey];
1374
+ if (list === undefined)
1375
+ continue;
1376
+ // A list written as something other than an array would otherwise fall back to the union's
1377
+ // bland "approvals: Invalid input" — the same message this whole function exists to replace.
1378
+ // `escalate` is exempt: its non-array shape is the retired severity threshold and gets its own
1379
+ // migration message from `collectRetiredApprovalsIssues`.
1380
+ if (!Array.isArray(list)) {
1381
+ if (listKey !== 'escalate') {
1382
+ issues.push({
1383
+ path: `${pathPrefix}.${listKey}`,
1384
+ message: `must be a LIST of rule entries, not ${typeof list === 'object' ? 'an object' : `a ${typeof list}`}. ` +
1385
+ `Write it as an array, e.g. [ ${renderApprovalEntryForString('npm test')} ]. ` +
1386
+ MIGRATION_HINT,
1387
+ });
1388
+ }
1389
+ continue;
1390
+ }
1391
+ list.forEach((entry, index) => {
1392
+ const entryPath = `${pathPrefix}.${listKey}[${index}]`;
1393
+ if (typeof entry === 'string') {
1394
+ issues.push({
1395
+ path: entryPath,
1396
+ message: 'bare strings are no longer accepted in an approvals rule list. Write the entry ' +
1397
+ `explicitly: ${renderApprovalEntryForString(entry)} — type, matcher and pattern are ` +
1398
+ `always required, and "matcher" may be exact, glob or regexp. ${MIGRATION_HINT}`,
1399
+ });
1400
+ return;
1401
+ }
1402
+ const parsed = approvalEntrySchema.safeParse(entry);
1403
+ if (parsed.success)
1404
+ return;
1405
+ for (const issue of parsed.error.issues) {
1406
+ issues.push({
1407
+ path: issue.path.length > 0 ? `${entryPath}.${issue.path.join('.')}` : entryPath,
1408
+ message: issue.message,
1409
+ });
1410
+ }
1411
+ });
1412
+ }
1413
+ }
1414
+ /**
1415
+ * EXT-70 §4.7/§9 — validate one `approvals.mcp` block, with a path that names the offending field.
1416
+ *
1417
+ * It runs PRE-PARSE for the same reason the rule entries do: `approvalsSchema` is a `z.union`, so a
1418
+ * bad `mcp` block otherwise collapses into the union's bland "approvals: Invalid input" — the
1419
+ * message this whole family of checks exists to replace. Here the user gets the server key, the
1420
+ * field and (for a hint name) the value they mistyped.
1421
+ */
1422
+ function collectMcpApprovalsIssues(approvals, pathPrefix, issues) {
1423
+ if (!approvals || typeof approvals !== 'object' || Array.isArray(approvals))
1424
+ return;
1425
+ const mcp = approvals.mcp;
1426
+ if (mcp === undefined)
1427
+ return;
1428
+ const parsed = mcpApprovalsSchema.safeParse(mcp);
1429
+ if (parsed.success)
1430
+ return;
1431
+ for (const issue of parsed.error.issues) {
1432
+ issues.push({
1433
+ path: issue.path.length > 0 ? `${pathPrefix}.mcp.${issue.path.join('.')}` : `${pathPrefix}.mcp`,
1434
+ message: issue.message,
1435
+ });
1436
+ }
1437
+ }
1438
+ /**
1439
+ * EXT-71 §3.1 — every hard error the rule grammar defines, found on the RAW input: each entry in
1440
+ * `allow`/`deny`/`escalate` validated with a path that names the offending field
1441
+ * ({@link collectApprovalEntryIssues}), the `approvals.mcp` block ({@link collectMcpApprovalsIssues}),
1442
+ * and the two `mcpServers` keys no rule can refer to — `*` ({@link RESERVED_MCP_SERVER_NAME}, which
1443
+ * an entry already reads as "every server") and the empty name
1444
+ * ({@link UNNAMEABLE_MCP_SERVER_NAME}, which no entry's `server` field can hold). Both need to see
1445
+ * `mcpServers`, a sibling of `approvals` rather than a field of it.
1446
+ *
1447
+ * All of them are HARD errors, reported the same way {@link findDeprecatedConfigIssues} reports
1448
+ * its own, and — like it — this runs BEFORE the schema parse so the precise message is the only
1449
+ * one the user sees.
1450
+ *
1451
+ * PURE: it only reads the object. Kept separate from {@link findDeprecatedConfigIssues} because
1452
+ * these are not removed pre-2.0 shapes; they are rules of the current grammar.
1453
+ */
1454
+ export function findApprovalsGrammarIssues(raw) {
1455
+ const issues = [];
1456
+ const mcpServers = raw.mcpServers;
1457
+ if (mcpServers && typeof mcpServers === 'object' && !Array.isArray(mcpServers)) {
1458
+ if (Object.prototype.hasOwnProperty.call(mcpServers, RESERVED_MCP_SERVER_NAME)) {
1459
+ issues.push({
1460
+ path: `mcpServers.${RESERVED_MCP_SERVER_NAME}`,
1461
+ message: `"${RESERVED_MCP_SERVER_NAME}" is a reserved MCP server name: an approvals rule entry ` +
1462
+ `uses it to mean EVERY server, so a server of that name would make ` +
1463
+ `{ "type": "mcpTool", "server": "${RESERVED_MCP_SERVER_NAME}", ... } ambiguous. ` +
1464
+ 'Rename the server to anything else. ' +
1465
+ MIGRATION_HINT,
1466
+ });
1467
+ }
1468
+ if (Object.prototype.hasOwnProperty.call(mcpServers, UNNAMEABLE_MCP_SERVER_NAME)) {
1469
+ issues.push({
1470
+ path: 'mcpServers.""',
1471
+ message: 'an MCP server may not be keyed with an empty name: both an approvals rule entry ' +
1472
+ '({ "type": "mcpTool", "server": ... }) and a trust relationship under ' +
1473
+ '"approvals.mcp.servers" require a server name of at least one character, so nothing ' +
1474
+ "could ever be written about this server's tools — every call it makes would be gated " +
1475
+ 'with no way to say otherwise. Give the server a name. ' +
1476
+ MIGRATION_HINT,
1477
+ });
1478
+ }
1479
+ }
1480
+ const collect = (approvals, prefix) => {
1481
+ collectApprovalEntryIssues(approvals, prefix, issues);
1482
+ collectMcpApprovalsIssues(approvals, prefix, issues);
1483
+ };
1484
+ collect(raw.approvals, 'approvals');
306
1485
  const commands = raw.commands;
307
- if (commands && typeof commands === 'object') {
1486
+ if (commands && typeof commands === 'object' && !Array.isArray(commands)) {
308
1487
  for (const [name, cmd] of Object.entries(commands)) {
309
- if (cmd && typeof cmd === 'object') {
310
- remapDeprecated(cmd, DEPRECATED_COMMAND_PAIRS, ` in commands.${name}`, warnings);
1488
+ if (cmd && typeof cmd === 'object' && !Array.isArray(cmd)) {
1489
+ collect(cmd.approvals, `commands.${name}.approvals`);
311
1490
  }
312
1491
  }
313
1492
  }
314
- return { config: raw, warnings };
1493
+ return issues;
1494
+ }
1495
+ /**
1496
+ * CFG-26 — collect every `approvals.rater` in a raw config (root + each `commands.<name>`).
1497
+ * CFG-27 flattened the key: it is a BARE identity-profile name, not `rater.profile`.
1498
+ *
1499
+ * PURE — it only reads the object; the caller decides whether each name RESOLVES. That split is
1500
+ * deliberate: profile resolution needs the filesystem, and `schema.ts` must stay pure so
1501
+ * `z.toJSONSchema` and every spec that validates a config object stay cwd-independent. The loader
1502
+ * pairs this with `resolveIdentityProfileConfigPath` to enforce the GS2-62 rule that an
1503
+ * unresolvable profile is a hard error, never a silent fallback to the main model.
1504
+ */
1505
+ export function findApprovalsRaterProfiles(raw) {
1506
+ const refs = [];
1507
+ const collect = (approvals, prefix) => {
1508
+ // The scalar sugar form (`"approvals": "assisted"`) carries no rater.
1509
+ if (!approvals || typeof approvals !== 'object' || Array.isArray(approvals))
1510
+ return;
1511
+ const rater = approvals.rater;
1512
+ if (typeof rater === 'string' && rater.trim().length > 0) {
1513
+ refs.push({ path: `${prefix}.rater`, profile: rater.trim() });
1514
+ }
1515
+ };
1516
+ collect(raw.approvals, 'approvals');
1517
+ const commands = raw.commands;
1518
+ if (commands && typeof commands === 'object' && !Array.isArray(commands)) {
1519
+ for (const [name, cmd] of Object.entries(commands)) {
1520
+ if (cmd && typeof cmd === 'object' && !Array.isArray(cmd)) {
1521
+ collect(cmd.approvals, `commands.${name}.approvals`);
1522
+ }
1523
+ }
1524
+ }
1525
+ return refs;
1526
+ }
1527
+ /**
1528
+ * CFG-26 — the ONE message for an `approvals.rater.profile` that does not resolve. Shared by the
1529
+ * loader (which hard-exits a real run) and {@link validateRawGthConfig} (which backs
1530
+ * `gth config validate`), so the validator can never green-light a config the runtime refuses.
1531
+ */
1532
+ export function unresolvedRaterProfileMessage(ref, searchedDir) {
1533
+ // `searchedDir` is what the CALLER actually looked in. It is a parameter rather than a constant
1534
+ // because a `--global` run searches `~/.gsloth/.gsloth-settings/<name>/`, and this module knows
1535
+ // nothing of the filesystem; naming the project dir there would send the user to the wrong place.
1536
+ const dir = searchedDir ?? `${GSLOTH_DIR}/${GSLOTH_SETTINGS_DIR}/${ref.profile}/`;
1537
+ return (`identity profile "${ref.profile}" not found ` +
1538
+ `(checked ${dir}). ` +
1539
+ 'Create it with `gth config profile create`, or omit approvals.rater to rate ' +
1540
+ 'with the main model.');
1541
+ }
1542
+ /**
1543
+ * Render {@link DeprecatedConfigIssue}s as the same ` - <path>: <message>` block used for
1544
+ * Zod validation errors, so a deprecated-shape rejection reads identically to a type-mismatch
1545
+ * rejection (loader wraps it with `Invalid configuration in <source>:`).
1546
+ */
1547
+ export function formatDeprecatedConfigIssues(issues) {
1548
+ return formatIssueLines(issues);
315
1549
  }
316
1550
  /**
317
1551
  * Generate the JSON Schema for the raw config from {@link rawGthConfigSchema} using
@@ -324,26 +1558,68 @@ export function generateConfigJsonSchema() {
324
1558
  /**
325
1559
  * Validate a freshly-loaded raw config object against {@link rawGthConfigSchema} and
326
1560
  * report the outcome as data (a pure function — no `displayWarning`/`exit`). Mirrors the
327
- * loader's policy: deprecated key names are pre-mapped (each yielding a warning), unknown
328
- * top-level keys warn but do not fail, and a genuine type mismatch on a known field fails
329
- * with a path-scoped message.
1561
+ * loader's policy: a removed pre-2.0 shape (a top-level command key or a deprecated
1562
+ * `*Provider*` name) is a HARD failure that names the fix, unknown top-level keys warn but
1563
+ * do not fail, and a genuine type mismatch on a known field fails with a path-scoped message.
330
1564
  *
331
- * A shallow copy is taken before the (mutating) deprecated-name pre-map so the caller's
332
- * top-level object is not modified.
1565
+ * Read-only: `findDeprecatedConfigIssues`, `findUnknownTopLevelKeys` and `safeParse` never
1566
+ * mutate `raw`, so no defensive copy is needed.
333
1567
  */
334
- export function validateRawGthConfig(raw) {
1568
+ export function validateRawGthConfig(raw, options) {
335
1569
  const warnings = [];
336
- const { config, warnings: deprecationWarnings } = preMapDeprecatedConfigNames({ ...raw });
337
- warnings.push(...deprecationWarnings);
338
- const unknownKeys = findUnknownTopLevelKeys(config);
339
- if (unknownKeys.length > 0) {
340
- warnings.push(`Unknown top-level config ${unknownKeys.length === 1 ? 'key' : 'keys'}: ` +
341
- `${unknownKeys.join(', ')} (kept as-is but ignored by Gaunt Sloth; check for typos).`);
1570
+ // Only an object config can carry deprecated/unknown keys. A null/array/primitive config is
1571
+ // handed straight to safeParse (below), which returns a clean "expected object" failure — the
1572
+ // scans would otherwise throw a raw TypeError, and coercing to {} would wrongly report ok:true.
1573
+ if (isRecordConfig(raw)) {
1574
+ // Removed pre-2.0 shapes short-circuit before the unknown-key warning: any deprecated name
1575
+ // present is hard-rejected here (and so never doubles as an unknown-key warning).
1576
+ const deprecatedIssues = findDeprecatedConfigIssues(raw);
1577
+ if (deprecatedIssues.length > 0) {
1578
+ return {
1579
+ ok: false,
1580
+ warnings: [],
1581
+ errorMessage: formatDeprecatedConfigIssues(deprecatedIssues),
1582
+ };
1583
+ }
1584
+ // EXT-71 — the rule-grammar errors that need to be seen before the schema parse, so the
1585
+ // message that explains the fix is the only one the user reads.
1586
+ const grammarIssues = findApprovalsGrammarIssues(raw);
1587
+ if (grammarIssues.length > 0) {
1588
+ return {
1589
+ ok: false,
1590
+ warnings: [],
1591
+ errorMessage: formatDeprecatedConfigIssues(grammarIssues),
1592
+ };
1593
+ }
1594
+ const unknownKeys = findUnknownTopLevelKeys(raw);
1595
+ if (unknownKeys.length > 0) {
1596
+ warnings.push(`Unknown top-level config ${unknownKeys.length === 1 ? 'key' : 'keys'}: ` +
1597
+ `${unknownKeys.join(', ')} (kept as-is but ignored by Gaunt Sloth; check for typos).`);
1598
+ }
342
1599
  }
343
- const result = rawGthConfigSchema.safeParse(config);
1600
+ const result = rawGthConfigSchema.safeParse(raw);
344
1601
  if (!result.success) {
345
1602
  return { ok: false, warnings, errorMessage: formatConfigValidationError(result.error) };
346
1603
  }
1604
+ // CFG-26 — `approvals.rater.profile` strict resolution, when the caller supplies a resolver.
1605
+ // The predicate is INJECTED rather than imported so this module stays pure (it feeds
1606
+ // `z.toJSONSchema`, and every spec that validates a config object must stay cwd-independent),
1607
+ // while `gth config validate` still agrees with the loader instead of green-lighting a config
1608
+ // the next real run hard-exits on. Without a resolver the check is skipped, preserving the
1609
+ // in-memory callers (e.g. the profile scaffolder).
1610
+ if (options?.resolveProfile && isRecordConfig(raw)) {
1611
+ const unresolved = findApprovalsRaterProfiles(raw).filter((ref) => !options.resolveProfile(ref.profile));
1612
+ if (unresolved.length > 0) {
1613
+ return {
1614
+ ok: false,
1615
+ warnings,
1616
+ errorMessage: formatIssueLines(unresolved.map((ref) => ({
1617
+ path: ref.path,
1618
+ message: unresolvedRaterProfileMessage(ref, options.describeProfileDir?.(ref.profile)),
1619
+ }))),
1620
+ };
1621
+ }
1622
+ }
347
1623
  return { ok: true, warnings };
348
1624
  }
349
1625
  //# sourceMappingURL=schema.js.map