@gajae-code/coding-agent 0.13.3 → 0.14.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 (599) hide show
  1. package/CHANGELOG.md +184 -2
  2. package/README.md +2 -0
  3. package/bin/gajaessi.js +4 -0
  4. package/dist/types/async/job-manager.d.ts +55 -2
  5. package/dist/types/async/support.d.ts +14 -0
  6. package/dist/types/capability/mcp.d.ts +6 -0
  7. package/dist/types/capability/types.d.ts +6 -0
  8. package/dist/types/cli/accounts-cli.d.ts +19 -0
  9. package/dist/types/cli/args.d.ts +3 -0
  10. package/dist/types/cli/config-cli.d.ts +6 -0
  11. package/dist/types/cli/crash-cli.d.ts +10 -0
  12. package/dist/types/cli/customize-doctor.d.ts +145 -0
  13. package/dist/types/cli/daemon-cli.d.ts +3 -1
  14. package/dist/types/cli/mcp-cli.d.ts +11 -0
  15. package/dist/types/cli/notify-cli.d.ts +1 -1
  16. package/dist/types/cli/root-flags.d.ts +12 -0
  17. package/dist/types/cli/setup-cli.d.ts +3 -1
  18. package/dist/types/cli/skills-cli.d.ts +2 -1
  19. package/dist/types/cli/update-cli.d.ts +19 -1
  20. package/dist/types/cli.d.ts +6 -0
  21. package/dist/types/commands/accounts.d.ts +40 -0
  22. package/dist/types/commands/crash.d.ts +24 -0
  23. package/dist/types/commands/customize.d.ts +22 -0
  24. package/dist/types/commands/daemon.d.ts +1 -1
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/commands/setup.d.ts +6 -0
  27. package/dist/types/commands/skills.d.ts +6 -1
  28. package/dist/types/commands/update.d.ts +7 -0
  29. package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
  30. package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
  31. package/dist/types/config/config-schema-version.d.ts +10 -0
  32. package/dist/types/config/file-lock.d.ts +18 -0
  33. package/dist/types/config/model-profile-activation.d.ts +3 -1
  34. package/dist/types/config/model-registry.d.ts +9 -1
  35. package/dist/types/config/models-config-schema.d.ts +2 -0
  36. package/dist/types/config/settings-schema.d.ts +70 -6
  37. package/dist/types/config/settings.d.ts +63 -0
  38. package/dist/types/config/skill-settings-defaults.d.ts +26 -0
  39. package/dist/types/config/task-delegation.d.ts +39 -0
  40. package/dist/types/config/update-channel.d.ts +10 -0
  41. package/dist/types/coordinator-mcp/server.d.ts +5 -1
  42. package/dist/types/crash/index-store.d.ts +87 -0
  43. package/dist/types/crash/nudge.d.ts +50 -0
  44. package/dist/types/crash/record-loader.d.ts +17 -0
  45. package/dist/types/crash/report.d.ts +134 -0
  46. package/dist/types/crash/sanitize.d.ts +25 -0
  47. package/dist/types/cursor.d.ts +13 -2
  48. package/dist/types/customization/import.d.ts +37 -0
  49. package/dist/types/customization/index.d.ts +4 -0
  50. package/dist/types/customization/inventory.d.ts +25 -0
  51. package/dist/types/customization/mutations.d.ts +32 -0
  52. package/dist/types/customization/types.d.ts +115 -0
  53. package/dist/types/daemon/control-types.d.ts +8 -1
  54. package/dist/types/daemon/operator-contract.d.ts +4 -4
  55. package/dist/types/discovery/claude.d.ts +39 -1
  56. package/dist/types/discovery/codex.d.ts +39 -1
  57. package/dist/types/discovery/index.d.ts +2 -0
  58. package/dist/types/discovery/mcp-compat.d.ts +30 -0
  59. package/dist/types/edit/renderer.d.ts +23 -0
  60. package/dist/types/extensibility/extensions/runner.d.ts +3 -0
  61. package/dist/types/extensibility/extensions/types.d.ts +21 -4
  62. package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
  63. package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
  64. package/dist/types/extensibility/hooks/loader.d.ts +19 -5
  65. package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
  66. package/dist/types/extensibility/skill-management.d.ts +107 -0
  67. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
  68. package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
  69. package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
  70. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
  71. package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
  72. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
  73. package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
  74. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
  75. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
  76. package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
  77. package/dist/types/hooks/events.d.ts +60 -0
  78. package/dist/types/hooks/normalize.d.ts +83 -0
  79. package/dist/types/hooks/skill-state.d.ts +9 -2
  80. package/dist/types/main.d.ts +1 -1
  81. package/dist/types/modes/acp/abort-scope.d.ts +13 -0
  82. package/dist/types/modes/acp/acp-agent.d.ts +7 -4
  83. package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
  84. package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
  85. package/dist/types/modes/components/customization/index.d.ts +1 -0
  86. package/dist/types/modes/components/gajae-pet-widget.d.ts +8 -30
  87. package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
  88. package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
  89. package/dist/types/modes/components/oauth-selector.d.ts +10 -4
  90. package/dist/types/modes/components/pet-capability.d.ts +11 -25
  91. package/dist/types/modes/components/status-line/gh.d.ts +6 -2
  92. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  93. package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
  94. package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
  95. package/dist/types/modes/interactive-mode.d.ts +1 -0
  96. package/dist/types/modes/types.d.ts +1 -0
  97. package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
  98. package/dist/types/runtime-mcp/config.d.ts +9 -0
  99. package/dist/types/runtime-mcp/manager.d.ts +26 -1
  100. package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
  101. package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
  102. package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
  103. package/dist/types/runtime-mcp/protocol.d.ts +278 -0
  104. package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
  105. package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
  106. package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
  107. package/dist/types/runtime-mcp/types.d.ts +102 -10
  108. package/dist/types/sdk/acp/adapter.d.ts +26 -5
  109. package/dist/types/sdk/broker/broker.d.ts +3 -55
  110. package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
  111. package/dist/types/sdk/broker/session-index.d.ts +14 -5
  112. package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
  113. package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
  114. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
  115. package/dist/types/sdk/bus/config.d.ts +11 -0
  116. package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
  117. package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
  118. package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
  119. package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
  120. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
  121. package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
  122. package/dist/types/sdk/bus/index.d.ts +85 -99
  123. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
  124. package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
  125. package/dist/types/sdk/bus/notification-service.d.ts +52 -0
  126. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
  127. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
  128. package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
  129. package/dist/types/sdk/bus/session-control.d.ts +25 -1
  130. package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
  131. package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
  132. package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
  133. package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
  134. package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
  135. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
  136. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
  137. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
  138. package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
  139. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
  140. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
  141. package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
  142. package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
  143. package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
  144. package/dist/types/sdk/cli/rows.d.ts +2 -11
  145. package/dist/types/sdk/cli/session-cli.d.ts +15 -25
  146. package/dist/types/sdk/client/client.d.ts +97 -1
  147. package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
  148. package/dist/types/sdk/host/control/index.d.ts +1 -1
  149. package/dist/types/sdk/host/control/operations.d.ts +18 -5
  150. package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
  151. package/dist/types/sdk/host/host.d.ts +15 -1
  152. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  153. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  154. package/dist/types/sdk/host/session-runtime.d.ts +127 -7
  155. package/dist/types/sdk/host/types.d.ts +1 -1
  156. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  157. package/dist/types/sdk/index.d.ts +2 -1
  158. package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
  159. package/dist/types/sdk/lifecycle/client.d.ts +59 -0
  160. package/dist/types/sdk/lifecycle/index.d.ts +3 -0
  161. package/dist/types/sdk/lifecycle/service.d.ts +232 -0
  162. package/dist/types/sdk/mcp/server.d.ts +9 -6
  163. package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
  164. package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
  165. package/dist/types/sdk/prompt-status.d.ts +10 -3
  166. package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
  167. package/dist/types/sdk/receipt-state.d.ts +17 -0
  168. package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
  169. package/dist/types/sdk/router/index.d.ts +1 -0
  170. package/dist/types/sdk/router/session-router.d.ts +152 -0
  171. package/dist/types/sdk/session-list.d.ts +22 -0
  172. package/dist/types/sdk/session-reconnect.d.ts +43 -1
  173. package/dist/types/sdk/session.d.ts +34 -1
  174. package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
  175. package/dist/types/session/account-inventory.d.ts +86 -0
  176. package/dist/types/session/agent-session.d.ts +145 -6
  177. package/dist/types/session/agent-storage.d.ts +10 -0
  178. package/dist/types/session/coordinator-tool-label.d.ts +28 -0
  179. package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
  180. package/dist/types/session/messages.d.ts +1 -1
  181. package/dist/types/session/secure-token-file.d.ts +3 -0
  182. package/dist/types/session/session-manager.d.ts +40 -1
  183. package/dist/types/session/startup-auth-config.d.ts +44 -0
  184. package/dist/types/session/terminal-abort.d.ts +378 -0
  185. package/dist/types/session/yield-queue.d.ts +7 -0
  186. package/dist/types/session-import/claude.d.ts +26 -0
  187. package/dist/types/session-import/codex.d.ts +88 -0
  188. package/dist/types/session-import/command.d.ts +35 -0
  189. package/dist/types/session-import/detect.d.ts +22 -0
  190. package/dist/types/session-import/index.d.ts +7 -0
  191. package/dist/types/session-import/provider-codex.d.ts +22 -0
  192. package/dist/types/session-import/provider-service.d.ts +38 -0
  193. package/dist/types/session-import/redact.d.ts +23 -0
  194. package/dist/types/session-import/service.d.ts +47 -0
  195. package/dist/types/session-import/types.d.ts +120 -0
  196. package/dist/types/setup/paseo/check.d.ts +16 -0
  197. package/dist/types/setup/paseo/install-saga.d.ts +115 -0
  198. package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
  199. package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
  200. package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
  201. package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
  202. package/dist/types/setup/paseo/provider-config.d.ts +35 -0
  203. package/dist/types/setup/paseo/remove.d.ts +14 -0
  204. package/dist/types/setup/paseo/result-types.d.ts +59 -0
  205. package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
  206. package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
  207. package/dist/types/skill-state/active-state.d.ts +5 -1
  208. package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
  209. package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
  210. package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
  211. package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
  212. package/dist/types/slash-commands/types.d.ts +7 -1
  213. package/dist/types/task/executor.d.ts +10 -0
  214. package/dist/types/task/index.d.ts +3 -1
  215. package/dist/types/tools/ask-answer-registry.d.ts +6 -3
  216. package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
  217. package/dist/types/tools/bash.d.ts +2 -6
  218. package/dist/types/tools/browser/attach.d.ts +11 -0
  219. package/dist/types/tools/browser/launch.d.ts +20 -0
  220. package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
  221. package/dist/types/tools/browser/render.d.ts +1 -0
  222. package/dist/types/tools/browser.d.ts +3 -1
  223. package/dist/types/tools/image-gen.d.ts +9 -2
  224. package/dist/types/tools/index.d.ts +8 -0
  225. package/dist/types/tools/monitor.d.ts +1 -1
  226. package/dist/types/tools/skill-discovery.d.ts +8 -1
  227. package/dist/types/tools/subagent.d.ts +2 -0
  228. package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
  229. package/dist/types/utils/herdr-pane.d.ts +76 -0
  230. package/package.json +19 -9
  231. package/scripts/build-binary.ts +2 -1
  232. package/scripts/build-sdk-package-smoke.ts +2 -13
  233. package/scripts/capture-sticky-viewport-showcase.ts +0 -1
  234. package/scripts/compile-args.ts +2 -0
  235. package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
  236. package/scripts/generate-sdk-operation-inventory.ts +28 -0
  237. package/scripts/generate-telegram-baseline-manifest.ts +1 -0
  238. package/scripts/generate-tool-catalog.ts +6 -4
  239. package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
  240. package/src/async/job-manager.ts +352 -17
  241. package/src/async/support.ts +25 -0
  242. package/src/autoresearch/dashboard.ts +28 -13
  243. package/src/capability/index.ts +3 -1
  244. package/src/capability/mcp.ts +6 -0
  245. package/src/capability/types.ts +6 -0
  246. package/src/cli/accounts-cli.ts +395 -0
  247. package/src/cli/args.ts +23 -0
  248. package/src/cli/auth-broker-cli.ts +136 -66
  249. package/src/cli/auth-gateway-cli.ts +94 -70
  250. package/src/cli/config-cli.ts +27 -2
  251. package/src/cli/crash-cli.ts +78 -0
  252. package/src/cli/customize-doctor.ts +1605 -0
  253. package/src/cli/daemon-cli.ts +12 -3
  254. package/src/cli/fast-help.ts +1 -0
  255. package/src/cli/mcp-cli.ts +128 -60
  256. package/src/cli/notify-cli.ts +8 -14
  257. package/src/cli/root-flags.ts +8 -0
  258. package/src/cli/setup-cli.ts +145 -0
  259. package/src/cli/skills-cli.ts +38 -2
  260. package/src/cli/update-cli.ts +167 -12
  261. package/src/cli.ts +4 -0
  262. package/src/commands/accounts.ts +71 -0
  263. package/src/commands/crash.ts +35 -0
  264. package/src/commands/customize.ts +84 -0
  265. package/src/commands/daemon.ts +2 -2
  266. package/src/commands/deep-interview.ts +2 -0
  267. package/src/commands/launch.ts +1 -0
  268. package/src/commands/mcp.ts +12 -7
  269. package/src/commands/ralplan.ts +2 -0
  270. package/src/commands/sdk.ts +65 -32
  271. package/src/commands/setup.ts +5 -0
  272. package/src/commands/skills.ts +14 -4
  273. package/src/commands/ultragoal.ts +10 -1
  274. package/src/commands/update.ts +26 -15
  275. package/src/config/atomic-yaml-patch-worker.ts +57 -0
  276. package/src/config/atomic-yaml-patch.ts +769 -29
  277. package/src/config/config-schema-version.ts +10 -0
  278. package/src/config/file-lock.ts +70 -1
  279. package/src/config/mcp-schema.json +52 -14
  280. package/src/config/model-profile-activation.ts +20 -1
  281. package/src/config/model-profiles.ts +29 -19
  282. package/src/config/model-registry.ts +132 -21
  283. package/src/config/models-config-schema.ts +1 -1
  284. package/src/config/settings-schema.ts +76 -6
  285. package/src/config/settings.ts +4038 -199
  286. package/src/config/skill-settings-defaults.ts +41 -3
  287. package/src/config/task-delegation.ts +87 -0
  288. package/src/config/update-channel.ts +23 -0
  289. package/src/coordinator-mcp/server.ts +377 -308
  290. package/src/crash/index-store.ts +723 -0
  291. package/src/crash/nudge.ts +89 -0
  292. package/src/crash/record-loader.ts +149 -0
  293. package/src/crash/report.ts +551 -0
  294. package/src/crash/sanitize.ts +129 -0
  295. package/src/cursor.ts +36 -10
  296. package/src/customization/import.ts +946 -0
  297. package/src/customization/index.ts +4 -0
  298. package/src/customization/inventory.ts +335 -0
  299. package/src/customization/mutations.ts +141 -0
  300. package/src/customization/types.ts +223 -0
  301. package/src/daemon/control-types.ts +8 -1
  302. package/src/daemon/operator-contract.ts +6 -6
  303. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
  304. package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
  305. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  306. package/src/discovery/claude-hooks.ts +38 -0
  307. package/src/discovery/claude.ts +142 -209
  308. package/src/discovery/codex-hooks.ts +38 -0
  309. package/src/discovery/codex.ts +132 -198
  310. package/src/discovery/helpers.ts +8 -1
  311. package/src/discovery/index.ts +2 -0
  312. package/src/discovery/mcp-compat.ts +234 -0
  313. package/src/discovery/mcp-json.ts +6 -0
  314. package/src/edit/renderer.ts +40 -0
  315. package/src/edit/streaming.ts +4 -2
  316. package/src/exec/bash-executor.ts +24 -20
  317. package/src/export/html/index.ts +51 -2
  318. package/src/extensibility/extensions/loader.ts +9 -1
  319. package/src/extensibility/extensions/runner.ts +17 -4
  320. package/src/extensibility/extensions/types.ts +31 -6
  321. package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
  322. package/src/extensibility/gjc-plugins/installer.ts +9 -5
  323. package/src/extensibility/gjc-plugins/observability.ts +8 -2
  324. package/src/extensibility/gjc-plugins/registry.ts +13 -2
  325. package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
  326. package/src/extensibility/gjc-plugins/schema.ts +222 -39
  327. package/src/extensibility/hooks/loader.ts +154 -10
  328. package/src/extensibility/runtime-skill-discovery.ts +257 -60
  329. package/src/extensibility/skill-management.ts +368 -0
  330. package/src/extensibility/skills.ts +52 -16
  331. package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
  332. package/src/gjc-runtime/launch-worktree.ts +56 -2
  333. package/src/gjc-runtime/linux-proc.ts +7 -1
  334. package/src/gjc-runtime/ralplan-runtime.ts +100 -164
  335. package/src/gjc-runtime/session-state-lock.ts +992 -0
  336. package/src/gjc-runtime/session-state-sidecar.ts +644 -142
  337. package/src/gjc-runtime/state-writer.ts +39 -14
  338. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
  339. package/src/gjc-runtime/ultragoal-guard.ts +11 -4
  340. package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
  341. package/src/gjc-runtime/workflow-settings.ts +596 -0
  342. package/src/goals/tools/goal-tool.ts +6 -1
  343. package/src/hooks/events.ts +291 -0
  344. package/src/hooks/native-skill-hook.ts +4 -0
  345. package/src/hooks/normalize.ts +369 -0
  346. package/src/hooks/skill-state.ts +165 -46
  347. package/src/internal-urls/docs-index.generated.ts +40 -32
  348. package/src/main.ts +32 -5
  349. package/src/migrate/mcp-mapper.ts +25 -2
  350. package/src/modes/acp/abort-scope.ts +29 -0
  351. package/src/modes/acp/acp-agent.ts +423 -98
  352. package/src/modes/components/assistant-message.ts +6 -1
  353. package/src/modes/components/customization/customization-dashboard.ts +443 -0
  354. package/src/modes/components/customization/import-wizard.ts +372 -0
  355. package/src/modes/components/customization/index.ts +1 -0
  356. package/src/modes/components/gajae-pet-widget.ts +539 -213
  357. package/src/modes/components/irc-sidebar.ts +11 -0
  358. package/src/modes/components/iterm-pet-transport.ts +585 -0
  359. package/src/modes/components/oauth-selector.ts +277 -86
  360. package/src/modes/components/pet-capability.ts +39 -40
  361. package/src/modes/components/settings-selector.ts +9 -0
  362. package/src/modes/components/status-line/gh.ts +45 -1
  363. package/src/modes/components/status-line/segments.ts +16 -1
  364. package/src/modes/components/tool-status-header.ts +13 -3
  365. package/src/modes/controllers/command-controller.ts +20 -8
  366. package/src/modes/controllers/extension-ui-controller.ts +56 -257
  367. package/src/modes/controllers/input-controller.ts +10 -0
  368. package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
  369. package/src/modes/controllers/selector-controller.ts +177 -72
  370. package/src/modes/controllers/stt-controller.ts +3 -3
  371. package/src/modes/interactive-mode.ts +88 -16
  372. package/src/modes/print-mode.ts +35 -6
  373. package/src/modes/runtime-init.ts +21 -68
  374. package/src/modes/types.ts +1 -0
  375. package/src/modes/utils/injected-user-submission.ts +25 -0
  376. package/src/modes/utils/terminal-bell.ts +15 -1
  377. package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
  378. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  379. package/src/prompts/agents/critic.md +1 -1
  380. package/src/prompts/system/rlm-research.md +1 -1
  381. package/src/prompts/system/system-prompt.md +0 -2
  382. package/src/prompts/tools/bash.md +7 -1
  383. package/src/prompts/tools/browser.md +1 -1
  384. package/src/prompts/tools/cron.md +6 -6
  385. package/src/prompts/tools/read.md +0 -2
  386. package/src/prompts/tools/skill-discovery.md +4 -2
  387. package/src/runtime-mcp/client.ts +427 -23
  388. package/src/runtime-mcp/config.ts +37 -5
  389. package/src/runtime-mcp/manager-pool.test.ts +56 -0
  390. package/src/runtime-mcp/manager.ts +52 -2
  391. package/src/runtime-mcp/oauth-discovery.ts +13 -0
  392. package/src/runtime-mcp/oauth-flow.ts +46 -1
  393. package/src/runtime-mcp/pool-key.ts +5 -1
  394. package/src/runtime-mcp/pool.test.ts +9 -0
  395. package/src/runtime-mcp/protocol.test.ts +353 -0
  396. package/src/runtime-mcp/protocol.ts +624 -0
  397. package/src/runtime-mcp/scope-config.ts +78 -0
  398. package/src/runtime-mcp/tool-bridge.ts +44 -10
  399. package/src/runtime-mcp/transports/http.ts +84 -13
  400. package/src/runtime-mcp/types.ts +123 -12
  401. package/src/sdk/acp/adapter.ts +328 -97
  402. package/src/sdk/broker/broker.ts +160 -741
  403. package/src/sdk/broker/ensure.ts +9 -1
  404. package/src/sdk/broker/lifecycle.ts +510 -116
  405. package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
  406. package/src/sdk/broker/process-incarnation.ts +19 -0
  407. package/src/sdk/broker/session-index.ts +516 -117
  408. package/src/sdk/broker/startup-failure.ts +28 -5
  409. package/src/sdk/broker/transport.ts +1 -13
  410. package/src/sdk/bus/chat-daemon-cli.ts +4 -2
  411. package/src/sdk/bus/chat-daemon-control.ts +67 -6
  412. package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
  413. package/src/sdk/bus/config.ts +13 -0
  414. package/src/sdk/bus/conversation-store.ts +59 -171
  415. package/src/sdk/bus/daemon-paths.ts +20 -0
  416. package/src/sdk/bus/discord-conversation.ts +2 -0
  417. package/src/sdk/bus/discord-daemon.ts +524 -126
  418. package/src/sdk/bus/discord-live-provider.ts +206 -79
  419. package/src/sdk/bus/existing-thread-readiness.ts +4 -97
  420. package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
  421. package/src/sdk/bus/index.ts +2480 -456
  422. package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
  423. package/src/sdk/bus/lifecycle-commands.ts +2 -2
  424. package/src/sdk/bus/managed-daemon.ts +2 -145
  425. package/src/sdk/bus/notification-service.ts +196 -0
  426. package/src/sdk/bus/operator-runtime.ts +1 -1
  427. package/src/sdk/bus/prompt-reconciliation.ts +24 -15
  428. package/src/sdk/bus/reconciliation-store.ts +553 -24
  429. package/src/sdk/bus/session-control.ts +49 -3
  430. package/src/sdk/bus/slack-conversation.ts +4 -0
  431. package/src/sdk/bus/slack-daemon.ts +733 -134
  432. package/src/sdk/bus/slack-live-provider.ts +65 -15
  433. package/src/sdk/bus/slack-provider.ts +4 -0
  434. package/src/sdk/bus/slack-thread-binding.ts +9 -104
  435. package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
  436. package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
  437. package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
  438. package/src/sdk/bus/telegram-daemon-control.ts +3 -13
  439. package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
  440. package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
  441. package/src/sdk/bus/telegram-daemon.ts +2552 -2280
  442. package/src/sdk/bus/telegram-reference.ts +1 -226
  443. package/src/sdk/bus/telegram-setup.ts +2 -2
  444. package/src/sdk/bus/threaded-render.ts +1 -0
  445. package/src/sdk/bus/topic-registry.ts +279 -146
  446. package/src/sdk/cli/rows.ts +2 -35
  447. package/src/sdk/cli/session-cli.ts +776 -700
  448. package/src/sdk/client/client.ts +892 -1
  449. package/src/sdk/host/control/dispatch.ts +93 -58
  450. package/src/sdk/host/control/index.ts +1 -1
  451. package/src/sdk/host/control/operations.ts +18 -10
  452. package/src/sdk/host/control/runtime-gate.ts +40 -0
  453. package/src/sdk/host/host.ts +63 -12
  454. package/src/sdk/host/query/handlers.ts +66 -7
  455. package/src/sdk/host/reverse-leases.ts +2 -2
  456. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  457. package/src/sdk/host/session-runtime.test.ts +2787 -34
  458. package/src/sdk/host/session-runtime.ts +1489 -101
  459. package/src/sdk/host/surface-policy.ts +12 -8
  460. package/src/sdk/host/types.ts +1 -1
  461. package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
  462. package/src/sdk/host/websocket-transport.ts +85 -50
  463. package/src/sdk/index.ts +2 -1
  464. package/src/sdk/lifecycle/broker-client.ts +53 -0
  465. package/src/sdk/lifecycle/client.ts +234 -0
  466. package/src/sdk/lifecycle/index.ts +3 -0
  467. package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
  468. package/src/sdk/lifecycle/service.ts +720 -0
  469. package/src/sdk/mcp/server.ts +226 -188
  470. package/src/sdk/prompt-deadline-lease.ts +55 -0
  471. package/src/sdk/prompt-deadline-manager.ts +148 -0
  472. package/src/sdk/prompt-status.ts +11 -3
  473. package/src/sdk/protocol/operation-inventory.generated.json +272 -129
  474. package/src/sdk/protocol/operation-registry.ts +71 -8
  475. package/src/sdk/receipt-state.ts +43 -0
  476. package/src/sdk/reconciliation-extensions.ts +14 -0
  477. package/src/sdk/router/index.ts +1 -0
  478. package/src/sdk/router/session-router.ts +1658 -0
  479. package/src/sdk/session-list.ts +93 -0
  480. package/src/sdk/session-reconnect.ts +45 -1
  481. package/src/sdk/session.ts +716 -120
  482. package/src/sdk/transport/serve-cli.ts +18 -34
  483. package/src/session/account-inventory.ts +603 -0
  484. package/src/session/agent-session.ts +2857 -582
  485. package/src/session/agent-storage.ts +35 -0
  486. package/src/session/blob-store.ts +32 -1
  487. package/src/session/coordinator-tool-label.ts +40 -0
  488. package/src/session/internal/managed-session-scope.ts +4 -0
  489. package/src/session/internal/managed-session-storage.ts +173 -12
  490. package/src/session/messages.ts +1 -1
  491. package/src/session/secure-token-file.ts +80 -0
  492. package/src/session/session-manager.ts +730 -58
  493. package/src/session/session-storage.ts +25 -16
  494. package/src/session/startup-auth-config.ts +297 -0
  495. package/src/session/terminal-abort.ts +1253 -0
  496. package/src/session/yield-queue.ts +57 -15
  497. package/src/session-import/claude.ts +334 -0
  498. package/src/session-import/codex.ts +1033 -0
  499. package/src/session-import/command.ts +77 -0
  500. package/src/session-import/detect.ts +133 -0
  501. package/src/session-import/index.ts +7 -0
  502. package/src/session-import/provider-codex.ts +327 -0
  503. package/src/session-import/provider-service.ts +457 -0
  504. package/src/session-import/redact.ts +128 -0
  505. package/src/session-import/service.ts +1264 -0
  506. package/src/session-import/types.ts +148 -0
  507. package/src/setup/paseo/check.ts +213 -0
  508. package/src/setup/paseo/install-saga.ts +293 -0
  509. package/src/setup/paseo/json-publisher.ts +263 -0
  510. package/src/setup/paseo/orchestration-preferences.ts +86 -0
  511. package/src/setup/paseo/paseo-ownership.ts +224 -0
  512. package/src/setup/paseo/paseo-setup.ts +253 -0
  513. package/src/setup/paseo/provider-config.ts +139 -0
  514. package/src/setup/paseo/remove.ts +183 -0
  515. package/src/setup/paseo/result-types.ts +79 -0
  516. package/src/setup/paseo/setup-deps.ts +175 -0
  517. package/src/setup/paseo/skills-bridge.ts +260 -0
  518. package/src/skill-state/active-state.ts +21 -11
  519. package/src/slash-commands/acp-builtins.ts +9 -1
  520. package/src/slash-commands/builtin-registry.ts +98 -16
  521. package/src/slash-commands/helpers/credential-switch.ts +143 -0
  522. package/src/slash-commands/helpers/mcp.ts +9 -3
  523. package/src/slash-commands/helpers/usage-report.ts +61 -56
  524. package/src/slash-commands/types.ts +7 -2
  525. package/src/task/executor.ts +30 -11
  526. package/src/task/index.ts +66 -7
  527. package/src/task/render.ts +23 -9
  528. package/src/tools/ask-answer-registry.ts +23 -7
  529. package/src/tools/ask.ts +8 -4
  530. package/src/tools/bash-sleep-advisory.ts +115 -0
  531. package/src/tools/bash.ts +98 -18
  532. package/src/tools/browser/attach.ts +71 -3
  533. package/src/tools/browser/launch.ts +86 -13
  534. package/src/tools/browser/profile-discovery.ts +57 -7
  535. package/src/tools/browser/render.ts +8 -1
  536. package/src/tools/browser.ts +130 -21
  537. package/src/tools/computer.enforcement.test.ts +1 -1
  538. package/src/tools/computer.ts +3 -2
  539. package/src/tools/cron.ts +12 -1
  540. package/src/tools/image-gen.ts +27 -16
  541. package/src/tools/index.ts +8 -0
  542. package/src/tools/job.ts +33 -6
  543. package/src/tools/monitor.ts +41 -2
  544. package/src/tools/skill-discovery.ts +21 -29
  545. package/src/tools/subagent.ts +142 -24
  546. package/src/tools/tool-catalog.generated.ts +6 -6
  547. package/src/tools/ultragoal-ask-guard.ts +10 -3
  548. package/src/utils/herdr-pane.ts +345 -0
  549. package/src/utils/title-generator.ts +2 -0
  550. package/src/web/scrapers/index.ts +6 -4
  551. package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
  552. package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
  553. package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
  554. package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
  555. package/dist/types/runtime/lazy-service.test.d.ts +0 -1
  556. package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
  557. package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
  558. package/dist/types/runtime-mcp/config.test.d.ts +0 -1
  559. package/dist/types/runtime-mcp/http.test.d.ts +0 -1
  560. package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
  561. package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
  562. package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
  563. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
  564. package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
  565. package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
  566. package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
  567. package/dist/types/sdk/elevation/audit.d.ts +0 -93
  568. package/dist/types/sdk/elevation/capability.d.ts +0 -3
  569. package/dist/types/sdk/elevation/digest.d.ts +0 -72
  570. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
  571. package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
  572. package/dist/types/sdk/elevation/owner.d.ts +0 -48
  573. package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
  574. package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
  575. package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
  576. package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
  577. package/dist/types/session/auth-broker-config.d.ts +0 -13
  578. package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
  579. package/dist/types/skills/skills.test.d.ts +0 -1
  580. package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
  581. package/dist/types/task/token-log.test.d.ts +0 -1
  582. package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
  583. package/dist/types/tools/descriptors.test.d.ts +0 -1
  584. package/scripts/g005-daemon-smoke.ts +0 -180
  585. package/scripts/g011-daemon-path-smoke.ts +0 -153
  586. package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
  587. package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
  588. package/src/sdk/bus/telegram-cli.ts +0 -212
  589. package/src/sdk/elevation/allowlist.ts +0 -54
  590. package/src/sdk/elevation/audit.ts +0 -208
  591. package/src/sdk/elevation/capability.ts +0 -41
  592. package/src/sdk/elevation/digest.ts +0 -129
  593. package/src/sdk/elevation/dispatch-receipt.ts +0 -184
  594. package/src/sdk/elevation/grant-ledger.ts +0 -1142
  595. package/src/sdk/elevation/owner.ts +0 -109
  596. package/src/session/auth-broker-config.ts +0 -109
  597. /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
  598. /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
  599. /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
@@ -10,6 +10,8 @@
10
10
  * For tests, `Settings.isolated()` seeds explicit user/global settings:
11
11
  * const isolated = Settings.isolated({ "compaction.enabled": false });
12
12
  */
13
+
14
+ import * as nodeCrypto from "node:crypto";
13
15
  import * as fs from "node:fs";
14
16
  import * as os from "node:os";
15
17
  import * as path from "node:path";
@@ -17,6 +19,7 @@ import * as util from "node:util";
17
19
  import {
18
20
  getAgentDbPath,
19
21
  getAgentDir,
22
+ getConfigRootDir,
20
23
  getCustomThemesDir,
21
24
  getProjectDir,
22
25
  isEnoent,
@@ -30,6 +33,7 @@ import { YAML } from "bun";
30
33
  import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
31
34
  import type { ModelRole } from "../config/model-registry";
32
35
  import { loadCapability } from "../discovery";
36
+ import { extractWorkflowSetting, type WorkflowSettingKey } from "../gjc-runtime/workflow-settings";
33
37
  import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
34
38
  import {
35
39
  type NotificationSettingsReader,
@@ -39,6 +43,8 @@ import {
39
43
  import { AgentStorage } from "../session/agent-storage";
40
44
  import { type EditMode, type EditVariantMatch, normalizeEditMode } from "../utils/edit-mode";
41
45
  import {
46
+ type AtomicYamlConfigTransaction,
47
+ AtomicYamlConflictError,
42
48
  type AtomicYamlPatch,
43
49
  applyAtomicYamlPatches,
44
50
  applyAtomicYamlPatchesWithCurrent,
@@ -48,6 +54,7 @@ import {
48
54
  enqueueAtomicYamlOperation,
49
55
  reserveAtomicYamlUpdateSlot,
50
56
  setByPath,
57
+ withAtomicYamlConfigTransaction,
51
58
  } from "./atomic-yaml-patch";
52
59
  import { isModelSelectorValue, type ModelSelectorValue, normalizeModelSelectorValue } from "./model-selector-value";
53
60
 
@@ -79,6 +86,97 @@ export interface RawSettings {
79
86
 
80
87
  const UNSAFE_EDIT_VARIANT_PATTERNS = new Set(["__proto__", "constructor", "prototype"]);
81
88
 
89
+ const CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS: readonly WorkflowSettingKey[] = [
90
+ "gjc.deepInterview.ambiguityThreshold",
91
+ "gjc.ralplan.autoHandoff",
92
+ "gjc.ralplan.maxIterations",
93
+ "gjc.ralplan.maxReviewPassesPerLane",
94
+ "gjc.ultragoal.nudgeBudget",
95
+ ];
96
+
97
+ type StrictInvalidEvidenceEntry = { key: WorkflowSettingKey; value: unknown };
98
+
99
+ /** Pre-publication target state of a key the project migration will write. */
100
+ type ProjectTargetBeforeState = { present: boolean; value: unknown };
101
+
102
+ /**
103
+ * Guaranteed-invalid placeholder values persisted into project `config.yml` as
104
+ * fallback strict evidence when the retained `.gjc/settings.json` is malformed
105
+ * or unreadable AND the strict-invalid evidence sidecar (`.gjc/state/`) cannot
106
+ * be written. The strict resolver reads config.yml only, so each value keeps
107
+ * the ralplan exit-2 error observable until the user repairs the source.
108
+ */
109
+ const MALFORMED_SOURCE_STRICT_FALLBACK: readonly { key: WorkflowSettingKey; value: unknown }[] = [
110
+ { key: "gjc.ralplan.autoHandoff", value: "invalid-autoHandoff" },
111
+ { key: "gjc.ralplan.maxIterations", value: -1 },
112
+ { key: "gjc.ralplan.maxReviewPassesPerLane", value: -1 },
113
+ ];
114
+
115
+ type StrictInvalidEvidence =
116
+ | { version: 2; malformed: true; source: string }
117
+ | { version: 2; malformed?: false; keys: readonly StrictInvalidEvidenceEntry[]; source: string };
118
+
119
+ const WORKFLOW_MIGRATION_MARKER_VERSION = 1;
120
+
121
+ type WorkflowMigrationMarker = {
122
+ version: 1;
123
+ status: "pending" | "complete";
124
+ sourcePath: string;
125
+ backupPath: string;
126
+ targetPath: string;
127
+ /** Canonical (realpath) agent dir at migration time; a symlink repointed
128
+ * afterwards must not be treated as the same migration target. */
129
+ canonicalTargetDir?: string;
130
+ /** `dev:ino` of the target config.yml at migration time; detects a
131
+ * same-pathname profile REPLACEMENT (deleted + recreated), which realpath
132
+ * alone cannot. */
133
+ canonicalTargetIdentity?: string;
134
+ /** `dev:ino` of the config.yml FILE that received the migration write; a
135
+ * later atomic editor save or file replacement yields a new inode that must
136
+ * not be published as migration-owned. */
137
+ targetFileIdentity?: string;
138
+ sourceSha256: string;
139
+ migratedKeys: WorkflowSettingKey[];
140
+ startedAt: string;
141
+ /** The prior source hash (the migration-write ownership basis) when the
142
+ * reconcile rewrites the marker as pending; the resume accepts a backup
143
+ * matching either the new hash (after refresh) or this prior hash. */
144
+ priorSourceSha256?: string;
145
+ /** Per-key sha256 of the values written by an interrupted reconcile; the
146
+ * resume recognizes a target matching a recorded repair value as the
147
+ * reconcile's own write even after a further source edit. */
148
+ repairValueHashes?: Record<string, string>;
149
+ /** True once the reconcile's target repairs were actually applied (the
150
+ * pending marker is rewritten after the CAS-protected apply succeeds);
151
+ * only then are repairValueHashes treated as committed-write evidence. */
152
+ repairsApplied?: boolean;
153
+ /** Per-key sha256 of the target values BEFORE the interrupted reconcile's
154
+ * repairs; the resume recognizes a repair value as committed when the
155
+ * target CHANGED from this recorded state (even if the post-apply marker
156
+ * rewrite was not reached). */
157
+ preRepairTargetHashes?: Record<string, string>;
158
+ completedAt?: string;
159
+ };
160
+ /**
161
+ * Test-only seams for the config-root workflow migration. Production code
162
+ * never sets these; tests use them to interleave external filesystem changes
163
+ * at exact points of the migration state machine (mirroring
164
+ * `FileLockTestHooks` in file-lock.ts).
165
+ */
166
+ export const SettingsMigrationTestHooks: {
167
+ /** Fires after the no-replace backup copy is created and its identity
168
+ * (inode + sha256) has been captured, immediately before the source is
169
+ * re-hashed for the move verification. */
170
+ afterBackupIdentityCaptured?: (backupPath: string) => void | Promise<void>;
171
+ /** Fires after a quarantined backup is verified as this run's file,
172
+ * immediately before the quarantined entry is unlinked. */
173
+ beforeQuarantineRemoval?: (backupPath: string) => void | Promise<void>;
174
+ /** Fires immediately before the project migration's POST-publication marker
175
+ * re-read, after the migrated values already committed: test seams use it
176
+ * to make the marker unreadable so the rollback path is exercised. */
177
+ beforeProjectMarkerMerge?: () => void | Promise<void>;
178
+ } = {};
179
+
82
180
  type SettingsPatch = {
83
181
  readonly path: string;
84
182
  readonly value: unknown | undefined;
@@ -122,6 +220,13 @@ export interface SettingsOptions {
122
220
  inMemory?: boolean;
123
221
  /** Initial overrides */
124
222
  overrides?: Partial<Record<SettingPath, unknown>>;
223
+ /**
224
+ * Read the canonical config.yml from disk but never persist: no DB open,
225
+ * no legacy/config-root/project migrations, no file writes, renames, locks,
226
+ * or mtime changes. Used by read-only inspection surfaces (`gjc customize
227
+ * doctor`). When true, inMemory must be false so config.yml is still read.
228
+ */
229
+ readonly?: boolean;
125
230
  }
126
231
 
127
232
  function summarizeSettingsOptions(options: SettingsOptions | null): {
@@ -171,6 +276,36 @@ function isAtomicSettingsPath(path: string): boolean {
171
276
  // Path Utilities
172
277
  // ═══════════════════════════════════════════════════════════════════════════
173
278
 
279
+ /**
280
+ * Flatten an object to its leaf dotted paths (nested and dotted keys alike),
281
+ * used to verify publication proof before retiring a legacy source.
282
+ */
283
+ function flattenObjectPaths(node: unknown, prefix: string[] = []): string[] {
284
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
285
+ return [prefix.join(".")];
286
+ }
287
+ const paths: string[] = [];
288
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
289
+ paths.push(...flattenObjectPaths(value, [...prefix, key]));
290
+ }
291
+ return paths;
292
+ }
293
+
294
+ /**
295
+ * Whether a dotted path has a value present in an object (used to verify
296
+ * publication proof before retiring a legacy source).
297
+ */
298
+ function hasPathValue(obj: Record<string, unknown>, dottedPath: string): boolean {
299
+ let node: unknown = obj;
300
+ for (const segment of dottedPath.split(".")) {
301
+ if (node === null || typeof node !== "object" || !(segment in node)) {
302
+ return false;
303
+ }
304
+ node = (node as Record<string, unknown>)[segment];
305
+ }
306
+ return true;
307
+ }
308
+
174
309
  /**
175
310
  * Get a nested value from an object by path segments.
176
311
  */
@@ -236,6 +371,13 @@ function rawSettingsRecord(value: unknown): RawSettings | undefined {
236
371
  return value as RawSettings;
237
372
  }
238
373
 
374
+ const LEGACY_CUSTOM_IMAGE_PROVIDER_DIAGNOSTIC =
375
+ "Legacy custom image settings cannot be migrated automatically. Move the endpoint and credential to models.yml, configure an image-capable OpenAI Responses model, and then set modelRoles.image. The legacy settings were retained unchanged.";
376
+
377
+ function hasLegacyCustomImageProvider(raw: RawSettings): boolean {
378
+ return rawSettingsRecord(raw.providers)?.image === "custom";
379
+ }
380
+
239
381
  function shallowModelSelectorRecord(value: unknown): Record<string, ModelSelectorValue> {
240
382
  const record = rawSettingsRecord(value);
241
383
  if (!record) return {};
@@ -375,6 +517,9 @@ export class Settings implements NotificationSettingsReader {
375
517
  /** Legacy fallback migration warnings emitted once per settings instance. */
376
518
  #legacyFallbackMigrationWarnings = 0;
377
519
  #legacyFallbackMigrationGlobalFingerprint: string | undefined;
520
+ /** Legacy custom-image migration error emitted once per settings instance. */
521
+ #legacyCustomImageProviderDiagnosticLogged = false;
522
+
378
523
  #schemaReport: SettingsSchemaReport = { issues: [], valid: true };
379
524
  #schemaMigrationPending = false;
380
525
  /** A newer config schema must never be rewritten by legacy migrations. */
@@ -389,12 +534,15 @@ export class Settings implements NotificationSettingsReader {
389
534
 
390
535
  /** Whether to persist changes */
391
536
  #persist: boolean;
537
+ /** Read-only inspection mode: config.yml is read but never written/migrated. */
538
+ #readonly: boolean;
392
539
 
393
540
  private constructor(options: SettingsOptions = {}) {
394
541
  this.#cwd = path.normalize(options.cwd ?? getProjectDir());
395
542
  this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
396
543
  this.#configPath = options.inMemory ? null : path.resolve(this.#agentDir, "config.yml");
397
- this.#persist = !options.inMemory;
544
+ this.#persist = !options.inMemory && !options.readonly;
545
+ this.#readonly = options.readonly === true;
398
546
 
399
547
  if (options.overrides) {
400
548
  for (const [key, value] of Object.entries(options.overrides)) {
@@ -450,6 +598,19 @@ export class Settings implements NotificationSettingsReader {
450
598
  const instance = new Settings(options);
451
599
  return instance.#load();
452
600
  }
601
+ /**
602
+ * Load settings for read-only inspection without any persistence side-effects:
603
+ * no DB open, no legacy/config-root/project migrations, no file writes,
604
+ * renames, locks, or mtime changes. Reads the canonical config.yml and
605
+ * discovers/merges project settings exactly like the durable path, but the
606
+ * result is a transient snapshot. Does not affect the global singleton.
607
+ *
608
+ * Used by `gjc customize doctor` to honor the read-only product contract.
609
+ */
610
+ static loadReadonly(options: { cwd?: string; agentDir?: string }): Promise<Settings> {
611
+ const instance = new Settings({ ...options, readonly: true });
612
+ return instance.#load();
613
+ }
453
614
 
454
615
  /**
455
616
  * Create an isolated instance for testing with explicit user/global settings.
@@ -1157,23 +1318,59 @@ export class Settings implements NotificationSettingsReader {
1157
1318
  // ─────────────────────────────────────────────────────────────────────────
1158
1319
 
1159
1320
  async #load(): Promise<Settings> {
1160
- // Project settings load (loadCapability scans cwd) is independent of the
1161
- // persist chain (storage open → legacy migration → global config.yml read),
1162
- // so kick it off first and await after the persist chain completes. The
1163
- // persist steps remain sequential: migration may write config.yml, which
1164
- // #loadYaml then reads; migration's db fallback needs #storage opened.
1165
- const projectPromise = this.#loadProjectSettings();
1166
-
1167
1321
  try {
1168
1322
  if (this.#persist) {
1169
- this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
1170
- await this.#migrateFromLegacy();
1323
+ // The legacy agent-dir/database settings merge into config.yml BEFORE
1324
+ // the workflow migrations create the file: #migrateAgentDirAndDatabaseLegacy
1325
+ // drains the database rows even when config.yml already exists, but a
1326
+ // first upgrade run must not let the config-root workflow migration
1327
+ // create the file while the database has not been opened yet. Open the
1328
+ // database best-effort, retrying immediately; if it is genuinely
1329
+ // unavailable, run the workflow migrations anyway so their overrides
1330
+ // are never lost, then re-open and drain afterwards.
1331
+ if (!this.#storage) {
1332
+ try {
1333
+ this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
1334
+ } catch {
1335
+ try {
1336
+ this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
1337
+ } catch {
1338
+ // Continue without the database; the final retry below
1339
+ // re-opens it for the rest of the load.
1340
+ }
1341
+ }
1342
+ }
1343
+ await this.#migrateAgentDirAndDatabaseLegacy();
1344
+ // The workflow settings migrations write config.yml directly and must
1345
+ // run BEFORE project discovery: project discovery strips the retired
1346
+ // workflow keys from the retained .gjc/settings.json, so on a first
1347
+ // load the migrated config.yml value must already exist when the
1348
+ // project layer is scanned - otherwise settings.get()/gjc config
1349
+ // get/list return the schema default for one cycle.
1350
+ await this.#migrateConfigRootWorkflowSettings();
1351
+ await this.#migrateProjectWorkflowSettings();
1352
+ if (!this.#storage) {
1353
+ // The database was unavailable during the merge: re-open it now
1354
+ // (an unavailable/corrupt database still fails the full load, as
1355
+ // before) and drain the legacy settings into the already-created
1356
+ // config.yml - the merge handles an existing file absent-only.
1357
+ this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
1358
+ await this.#migrateAgentDirAndDatabaseLegacy();
1359
+ }
1171
1360
  this.#global = await this.#loadYaml(this.#configPath!);
1361
+ } else if (this.#readonly && this.#configPath) {
1362
+ // Read-only inspection path (e.g. `gjc customize doctor`): read
1363
+ // config.yml from disk without opening the DB, running legacy/
1364
+ // config-root/project migrations, or writing/renaming anything.
1365
+ // The effective configuration semantics are otherwise identical:
1366
+ // the same YAML parse, schema reconciliation, in-memory migration,
1367
+ // and project-layer discovery/merge are applied.
1368
+ this.#global = await this.#loadYaml(this.#configPath);
1172
1369
  }
1173
1370
  if (this.#schemaMigrationPending)
1174
1371
  this.#recordLegacyFallbackMigrationPatch("configSchemaVersion", CONFIG_SCHEMA_VERSION);
1175
1372
 
1176
- this.#project = await projectPromise;
1373
+ this.#project = await this.#loadProjectSettings();
1177
1374
 
1178
1375
  await this.#normalizeAfterLoad();
1179
1376
  if (this.#schemaReport.issues.length > 0) {
@@ -1270,6 +1467,22 @@ export class Settings implements NotificationSettingsReader {
1270
1467
  }
1271
1468
  const migrated = this.#migrateRawSettings(parsedRaw);
1272
1469
  const reconciled = reconcileSettingsSchema(migrated);
1470
+ if (filePath === this.#configPath && hasLegacyCustomImageProvider(parsedRaw)) {
1471
+ reconciled.report.issues.push({
1472
+ path: "providers.image",
1473
+ kind: "invalid",
1474
+ detail: LEGACY_CUSTOM_IMAGE_PROVIDER_DIAGNOSTIC,
1475
+ });
1476
+ reconciled.report.valid = false;
1477
+ if (!this.#legacyCustomImageProviderDiagnosticLogged) {
1478
+ this.#legacyCustomImageProviderDiagnosticLogged = true;
1479
+ logger.error("Settings: legacy custom image provider requires manual migration", {
1480
+ configPath: filePath,
1481
+ modelsPath: path.join(this.#agentDir, "models.yml"),
1482
+ });
1483
+ }
1484
+ }
1485
+
1273
1486
  if (typeof configSchemaVersion === "number" && configSchemaVersion > CONFIG_SCHEMA_VERSION) {
1274
1487
  reconciled.report.issues.push({
1275
1488
  path: "configSchemaVersion",
@@ -1287,20 +1500,65 @@ export class Settings implements NotificationSettingsReader {
1287
1500
  let merged: RawSettings = {};
1288
1501
  for (const item of result.items as SettingsCapabilityItem[]) {
1289
1502
  if (item.level !== "project") continue;
1290
- const { settings, rejectedNotifications } = this.#stripProjectNotificationSettings(
1291
- item.data as RawSettings,
1292
- );
1503
+ // Retained project settings.json remains discoverable for non-workflow
1504
+ // settings, but workflow keys durably migrated to config.yml must not be
1505
+ // resurrected after `gjc config unset`. Unowned keys stay visible as the
1506
+ // resolver's fallback when migration could not publish ownership.
1507
+ const data = item.path.endsWith(`${path.sep}settings.json`)
1508
+ ? await this.#stripRetiredWorkflowKeys(item.path, structuredClone(item.data as RawSettings))
1509
+ : (item.data as RawSettings);
1510
+ const { settings, rejectedNotifications, rejectedCredentialPins } =
1511
+ this.#stripProjectNotificationSettings(data);
1293
1512
  if (rejectedNotifications) {
1294
1513
  logger.warn("Settings: ignoring project notification settings", { path: item.path });
1295
1514
  }
1515
+ if (rejectedCredentialPins) {
1516
+ logger.warn("Settings: ignoring project auth.credentialPins; pins are global-only", { path: item.path });
1517
+ }
1296
1518
  merged = this.#deepMerge(merged, settings);
1297
1519
  }
1298
1520
  return this.#migrateRawSettings(merged);
1299
- } catch {
1521
+ } catch (error) {
1522
+ // A malformed project file is tolerated (its layer is skipped), but a
1523
+ // marker-read failure (EACCES/EISDIR/transient I/O - an fs error with
1524
+ // a code, not a parse error) must PROPAGATE: silently returning {}
1525
+ // would drop the entire project layer, including valid config.yml
1526
+ // values, on a transient failure.
1527
+ if (
1528
+ error !== null &&
1529
+ typeof error === "object" &&
1530
+ "code" in error &&
1531
+ (error as { code?: unknown }).code !== "ENOENT"
1532
+ ) {
1533
+ throw error;
1534
+ }
1300
1535
  return {};
1301
1536
  }
1302
1537
  }
1303
1538
 
1539
+ /**
1540
+ * Remove workflow keys from retained project settings.json only when the
1541
+ * migrated-keys marker durably records config.yml ownership. Unowned keys are
1542
+ * normalized from legacy flat dotted form to the nested settings shape so the
1543
+ * generic Settings view agrees with the workflow resolver's fallback.
1544
+ */
1545
+ async #stripRetiredWorkflowKeys(sourcePath: string, settings: RawSettings): Promise<RawSettings> {
1546
+ const owned = await this.#readProjectMigratedKeys(sourcePath);
1547
+ for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
1548
+ if (owned.has(key)) {
1549
+ if (Object.hasOwn(settings, key)) delete settings[key];
1550
+ deleteByPath(settings, key.split("."));
1551
+ continue;
1552
+ }
1553
+ if (Object.hasOwn(settings, key)) {
1554
+ const value = settings[key];
1555
+ delete settings[key];
1556
+ setByPath(settings, key.split("."), value);
1557
+ }
1558
+ }
1559
+ return settings;
1560
+ }
1561
+
1304
1562
  async #normalizeAfterLoad(): Promise<void> {
1305
1563
  this.#sanitizeModelSelectorRecords();
1306
1564
  this.#rebuildMerged();
@@ -1445,193 +1703,3740 @@ export class Settings implements NotificationSettingsReader {
1445
1703
  logger.warn(`Settings: ${message}`);
1446
1704
  }
1447
1705
 
1448
- async #migrateFromLegacy(): Promise<void> {
1449
- if (!this.#configPath) return;
1450
-
1451
- // Check if config.yml already exists
1452
- try {
1453
- await Bun.file(this.#configPath).text();
1454
- return; // Already exists, no migration needed
1455
- } catch (err) {
1456
- if (!isEnoent(err)) return;
1457
- }
1458
-
1459
- let settings: RawSettings = {};
1460
- let migrated = false;
1461
-
1462
- // 1. Migrate from settings.json
1463
- const settingsJsonPath = path.join(this.#agentDir, "settings.json");
1464
- try {
1465
- const parsed = JSON.parse(await Bun.file(settingsJsonPath).text());
1466
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1467
- settings = this.#deepMerge(settings, this.#migrateRawSettings(parsed));
1468
- migrated = true;
1469
- try {
1470
- fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`);
1471
- } catch {}
1472
- }
1473
- } catch {}
1474
-
1475
- // 2. Migrate from agent.db
1476
- try {
1477
- const dbSettings = this.#storage?.getSettings();
1478
- if (dbSettings) {
1479
- settings = this.#deepMerge(settings, this.#migrateRawSettings(dbSettings as RawSettings));
1480
- migrated = true;
1706
+ /**
1707
+ * Collect ABSENT-ONLY leaf patches from a legacy settings object against the
1708
+ * current config.yml root: a nested legacy value (e.g. `gjc.ultragoal.
1709
+ * nudgeBudget`) must not be skipped wholesale just because a sibling `gjc`
1710
+ * object already exists - each leaf is merged only when absent, so the
1711
+ * existing values (including the workflow values a migration wrote) are
1712
+ * never clobbered and every drained setting survives.
1713
+ */
1714
+ /**
1715
+ * Source-overwrite patches for the interrupted-retirement recovery: every
1716
+ * leaf of the source's migrated values becomes a SET patch with the
1717
+ * source's value. Path semantics mirror {@link #collectAbsentLegacyPatches}
1718
+ * (top-level keys split on dots; keys inside records are literal segments
1719
+ * and are never split), so a record with a literal dotted member such as
1720
+ * `modelTags: { "custom.role": { name } }` survives recovery.
1721
+ */
1722
+ #collectSourceOverwritePatches(value: RawSettings, prefix: string[] = []): AtomicYamlPatch[] {
1723
+ const patches: AtomicYamlPatch[] = [];
1724
+ for (const [key, entry] of Object.entries(value)) {
1725
+ const segments = prefix.length === 0 ? [...prefix, ...key.split(".")] : [...prefix, key];
1726
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
1727
+ patches.push(...this.#collectSourceOverwritePatches(entry as RawSettings, segments));
1728
+ } else {
1729
+ // A literal dotted member (e.g. `custom.role` inside a record) is
1730
+ // not representable in the dotted patch path format: the atomic
1731
+ // pipeline would re-split it into nested segments and either garble
1732
+ // the target or fail the publication. Exclude such leaves so the
1733
+ // recovery completes without corrupting the target's literal
1734
+ // member; the remaining source paths are republished.
1735
+ if (segments.some(segment => segment.includes("."))) continue;
1736
+ patches.push({ path: segments.join("."), op: "set" as const, value: structuredClone(entry) });
1481
1737
  }
1482
- } catch {}
1483
-
1484
- // 3. Write merged settings through the shared atomic YAML pipeline.
1485
- if (migrated && Object.keys(settings).length > 0) {
1486
- try {
1487
- await applyAtomicYamlPatches(
1488
- this.#configPath,
1489
- Object.entries(settings).map(([settingPath, value]) => ({
1490
- path: settingPath,
1491
- op: "set" as const,
1492
- value,
1493
- })),
1494
- );
1495
- logger.debug("Settings: migrated to config.yml", { path: this.#configPath });
1496
- } catch {}
1497
1738
  }
1739
+ return patches;
1498
1740
  }
1499
1741
 
1500
- #hasCustomThemeFile(name: string): boolean {
1501
- try {
1502
- return fs.existsSync(path.join(getCustomThemesDir(this.#agentDir), `${name}.json`));
1503
- } catch {
1504
- return false;
1742
+ #collectAbsentLegacyPatches(
1743
+ current: Readonly<Record<string, unknown>>,
1744
+ legacy: Readonly<Record<string, unknown>>,
1745
+ prefix: string[] = [],
1746
+ ): AtomicYamlPatch[] {
1747
+ const patches: AtomicYamlPatch[] = [];
1748
+ // Keys encountered INSIDE a record value are literal member names, not
1749
+ // dotted paths. A member key containing a dot (e.g. `modelTags` ->
1750
+ // `{ "custom.role": ... }`) cannot be addressed by the dotted patch
1751
+ // grammar as a leaf, so such a record is merged as a WHOLE at its
1752
+ // (dot-free) enclosing path instead of leaf-by-leaf. Only the top-level
1753
+ // legacy keys are flat dotted paths (the database's row-key format), and
1754
+ // they are never literal keys.
1755
+ if (prefix.length > 0 && Object.keys(legacy).some(key => key.includes("."))) {
1756
+ const currentRecord = getByPath(current, prefix);
1757
+ // An OCCUPIED non-record enclosing path (scalar, array, or null)
1758
+ // must be left unchanged: emitting a whole-record merge would
1759
+ // replace the modern value (e.g. `modelTags: "custom"`) and then
1760
+ // clear the legacy rows, violating the absent-only migration
1761
+ // contract. Only absent or record-valued paths participate.
1762
+ if (
1763
+ currentRecord !== undefined &&
1764
+ (currentRecord === null || typeof currentRecord !== "object" || Array.isArray(currentRecord))
1765
+ ) {
1766
+ return patches;
1767
+ }
1768
+ patches.push({
1769
+ path: prefix.join("."),
1770
+ op: "set" as const,
1771
+ value:
1772
+ currentRecord === undefined
1773
+ ? legacy
1774
+ : this.#mergeAbsentRecords(currentRecord as Record<string, unknown>, legacy),
1775
+ });
1776
+ return patches;
1505
1777
  }
1506
- }
1507
-
1508
- #migrateLegacyBuiltInThemeName(name: string): string {
1509
- if (isLegacyThemeName(name) && !this.#hasCustomThemeFile(name)) {
1510
- return LEGACY_THEME_NAME_REPLACEMENTS[name];
1778
+ for (const [key, value] of Object.entries(legacy)) {
1779
+ // Split flat dotted keys into path segments ONLY on the initial call so
1780
+ // the absent check uses the SAME nested interpretation the patch grammar
1781
+ // applies when writing: a legacy database row keyed `theme.dark` must
1782
+ // compare against the nested `theme.dark` value, not a literal top-level
1783
+ // "theme.dark" key (which would report absent and then overwrite the
1784
+ // modern value). Keys inside record values are literal segments and are
1785
+ // never split.
1786
+ const segments = prefix.length === 0 ? [...prefix, ...key.split(".")] : [...prefix, key];
1787
+ const currentValue = getByPath(current, segments);
1788
+ if (currentValue === undefined) {
1789
+ patches.push({ path: segments.join("."), op: "set" as const, value });
1790
+ continue;
1791
+ }
1792
+ if (
1793
+ currentValue !== null &&
1794
+ typeof currentValue === "object" &&
1795
+ !Array.isArray(currentValue) &&
1796
+ value !== null &&
1797
+ typeof value === "object" &&
1798
+ !Array.isArray(value)
1799
+ ) {
1800
+ patches.push(...this.#collectAbsentLegacyPatches(current, value as Record<string, unknown>, segments));
1801
+ }
1511
1802
  }
1512
- return name;
1803
+ return patches;
1513
1804
  }
1514
1805
 
1515
- #getThemeSlotForName(name: string): "dark" | "light" {
1516
- return isLightTheme(name, this.#agentDir) ? "light" : "dark";
1806
+ /**
1807
+ * Deep-merge a legacy record into the current record at the same path,
1808
+ * adding ONLY absent leaves so existing values are never clobbered. Every
1809
+ * member key is treated as a literal segment name at every level (no
1810
+ * dotted-path interpretation), which is what preserves a key like
1811
+ * `custom.role` inside a `modelTags` record during migration.
1812
+ */
1813
+ #mergeAbsentRecords(
1814
+ current: Readonly<Record<string, unknown>>,
1815
+ legacy: Readonly<Record<string, unknown>>,
1816
+ ): Record<string, unknown> {
1817
+ const merged: Record<string, unknown> = { ...current };
1818
+ for (const [key, value] of Object.entries(legacy)) {
1819
+ const existing = merged[key];
1820
+ if (existing === undefined) {
1821
+ merged[key] = value;
1822
+ } else if (
1823
+ existing !== null &&
1824
+ typeof existing === "object" &&
1825
+ !Array.isArray(existing) &&
1826
+ value !== null &&
1827
+ typeof value === "object" &&
1828
+ !Array.isArray(value)
1829
+ ) {
1830
+ merged[key] = this.#mergeAbsentRecords(
1831
+ existing as Record<string, unknown>,
1832
+ value as Record<string, unknown>,
1833
+ );
1834
+ }
1835
+ }
1836
+ return merged;
1517
1837
  }
1518
1838
 
1519
- /** Apply registered schema migrations once, using configSchemaVersion as the durable marker. */
1520
- #migrateRawSettings(raw: RawSettings): RawSettings {
1521
- const configuredVersion = raw.configSchemaVersion;
1522
- if (configuredVersion === CONFIG_SCHEMA_VERSION) return raw;
1523
- if (typeof configuredVersion === "number" && configuredVersion > CONFIG_SCHEMA_VERSION) return raw;
1839
+ async #migrateAgentDirAndDatabaseLegacy(): Promise<void> {
1840
+ if (!this.#configPath) return;
1524
1841
 
1525
- // Migration registry v0 -> v1.
1526
- // queueMode -> steeringMode
1527
- normalizeSessionDirectoryMigration(raw);
1528
- if ("queueMode" in raw && !("steeringMode" in raw)) {
1529
- raw.steeringMode = raw.queueMode;
1530
- delete raw.queueMode;
1842
+ let settings: RawSettings = {};
1843
+ let migrated = false;
1844
+ // The source-only migrated values (tracked for the interrupted-retirement
1845
+ // recovery: their CURRENT values must be REPUBLISHED, not absent-only, so
1846
+ // a concurrent edit after the interrupted run is never silently lost).
1847
+ let settingsJsonMigrated: RawSettings | null = null;
1848
+
1849
+ // 1. Migrate from settings.json (one-time via the .bak rename; runs only
1850
+ // when config.yml is absent so it never overwrites a completed surface,
1851
+ // EXCEPT when a pending-retirement marker exists: a source edited after
1852
+ // an interrupted retirement must be RE-MIGRATED (absent-only) instead of
1853
+ // staying permanently ignored by the configExists guard).
1854
+ const configExists = await this.#pathExists(this.#configPath);
1855
+ const settingsJsonPath = path.join(this.#agentDir, "settings.json");
1856
+ const pendingRetirementPath = `${settingsJsonPath}.pending-retirement`;
1857
+ const settingsJsonRetirementMarkerExists = await this.#pathExists(pendingRetirementPath);
1858
+ let settingsJsonRetirementPending = false;
1859
+ let settingsJsonMarkerPersisted = false;
1860
+ let settingsJsonRaw: string | null = null;
1861
+ // Identity (inode) of the VALIDATED bytes, captured from the same
1862
+ // descriptor that read them: the retirement rename is bound to this
1863
+ // identity so a concurrent replacement is never moved to .bak.
1864
+ let settingsJsonIno: number | null = null;
1865
+ // The marker's ORIGINAL sha at method entry (captured BEFORE the
1866
+ // re-read rewrites it): the recovery's source-set patches must compare
1867
+ // the current source against the INTERRUPTED run's recorded revision,
1868
+ // not the just-rewritten marker.
1869
+ let settingsJsonMarkerShaAtEntry: string | null = null;
1870
+ if (settingsJsonRetirementMarkerExists) {
1871
+ settingsJsonMarkerShaAtEntry = await Bun.file(pendingRetirementPath)
1872
+ .text()
1873
+ .catch(() => null);
1531
1874
  }
1532
- // ask.timeout: v0 stored milliseconds; v1 stores seconds.
1533
- if (raw.ask && typeof (raw.ask as Record<string, unknown>).timeout === "number") {
1534
- const oldValue = (raw.ask as Record<string, unknown>).timeout as number;
1535
- if (oldValue > 1000) (raw.ask as Record<string, unknown>).timeout = Math.round(oldValue / 1000);
1875
+ if (!configExists || settingsJsonRetirementMarkerExists) {
1876
+ try {
1877
+ // Read the bytes and capture the inode from the SAME descriptor:
1878
+ // the retirement rename is bound to the file whose bytes were
1879
+ // validated, so a concurrent replacement (even between the read and
1880
+ // a later stat) is never retired.
1881
+ const sourceHandle = await fs.promises.open(settingsJsonPath, "r");
1882
+ try {
1883
+ settingsJsonIno = (await sourceHandle.stat()).ino;
1884
+ settingsJsonRaw = await sourceHandle.readFile("utf8");
1885
+ } finally {
1886
+ await sourceHandle.close();
1887
+ }
1888
+ const parsed = JSON.parse(settingsJsonRaw);
1889
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1890
+ settingsJsonMigrated = this.#migrateRawSettings(parsed);
1891
+ settings = this.#deepMerge(settings, settingsJsonMigrated);
1892
+ migrated = true;
1893
+ // The .bak retirement is DEFERRED until the combined migration
1894
+ // commits: a later failure (e.g. a malformed agent.db row that
1895
+ // aborts the load) must leave the source discoverable for the
1896
+ // next load instead of stranding its only copy as a .bak.
1897
+ settingsJsonRetirementPending = true;
1898
+ // Persist the pending retirement BEFORE the publication: if the
1899
+ // process exits after the values commit but before the rename,
1900
+ // the next load recognizes the marker and completes the
1901
+ // retirement instead of stranding an active-looking legacy file
1902
+ // whose edits are silently ignored. The marker stores the
1903
+ // source's SHA-256 so a later edit is never retired. The payload
1904
+ // and parent directory are FSYNCED (like the YAML publication
1905
+ // it recovers) so a power loss cannot strand the source with a
1906
+ // lost marker while config.yml already exists. A replaced marker
1907
+ // (a previous interrupted run) is overwritten.
1908
+ try {
1909
+ const markerHandle = await fs.promises.open(pendingRetirementPath, "w", 0o600);
1910
+ try {
1911
+ await markerHandle.writeFile(
1912
+ nodeCrypto.createHash("sha256").update(settingsJsonRaw).digest("hex"),
1913
+ );
1914
+ await markerHandle.sync();
1915
+ } finally {
1916
+ await markerHandle.close();
1917
+ }
1918
+ await fs.promises
1919
+ .open(path.dirname(pendingRetirementPath), "r")
1920
+ .then(async dirHandle => {
1921
+ try {
1922
+ await dirHandle.sync();
1923
+ } finally {
1924
+ await dirHandle.close();
1925
+ }
1926
+ })
1927
+ .catch(() => undefined);
1928
+ settingsJsonMarkerPersisted = true;
1929
+ } catch {}
1930
+ }
1931
+ } catch {}
1536
1932
  }
1537
1933
 
1538
- // Migrate old flat "theme" string to nested theme.dark/theme.light
1539
- if (typeof raw.theme === "string") {
1540
- const oldTheme = raw.theme;
1541
- const migratedTheme = this.#migrateLegacyBuiltInThemeName(oldTheme);
1542
- if (oldTheme === "dark" && migratedTheme === "red-claw") {
1543
- raw.theme = { dark: migratedTheme };
1544
- } else if (oldTheme === "light" && migratedTheme === "blue-crab") {
1545
- raw.theme = { light: migratedTheme };
1546
- } else {
1547
- const slot = this.#getThemeSlotForName(migratedTheme);
1548
- raw.theme = { [slot]: migratedTheme };
1549
- }
1550
- } else if (raw.theme && typeof raw.theme === "object" && !Array.isArray(raw.theme)) {
1551
- const themeObj = raw.theme as Record<string, unknown>;
1552
- if (typeof themeObj.dark === "string") {
1553
- themeObj.dark = this.#migrateLegacyBuiltInThemeName(themeObj.dark);
1554
- }
1555
- if (typeof themeObj.light === "string") {
1556
- themeObj.light = this.#migrateLegacyBuiltInThemeName(themeObj.light);
1934
+ // 2. Migrate from agent.db (self-draining: the rows are cleared after the
1935
+ // merge, so a later load never re-imports them; runs even when config.yml
1936
+ // already exists so a database that became available after an earlier
1937
+ // failed open is still drained). A transient read failure (e.g.
1938
+ // SQLITE_BUSY escaping the busy timeout) is retried with backoff; on a
1939
+ // persistent failure the rows did NOT participate in the publication, so
1940
+ // the drain below is skipped and they stay for the next load - never
1941
+ // delete database-only rows that were not merged into config.yml.
1942
+ let dbReadSucceeded = false;
1943
+ try {
1944
+ let dbSettings: RawSettings | null = null;
1945
+ for (let attempt = 0; attempt < 3; attempt++) {
1946
+ try {
1947
+ dbSettings = (this.#storage?.getSettings() as RawSettings | null) ?? null;
1948
+ break;
1949
+ } catch (error) {
1950
+ const isSqliteBusy =
1951
+ error && typeof error === "object" && (error as { code?: string }).code === "SQLITE_BUSY";
1952
+ if (!isSqliteBusy || attempt === 2) throw error;
1953
+ await Bun.sleep(100 * 2 ** attempt);
1954
+ }
1557
1955
  }
1558
- }
1559
-
1560
- // task.isolation.enabled (boolean) -> task.isolation.mode (enum)
1561
- const taskObj = raw.task as Record<string, unknown> | undefined;
1562
- const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
1563
- if (isolationObj && "enabled" in isolationObj) {
1564
- if (typeof isolationObj.enabled === "boolean") {
1565
- isolationObj.mode = isolationObj.enabled ? "auto" : "none";
1956
+ if (dbSettings) {
1957
+ settings = this.#deepMerge(settings, this.#migrateRawSettings(dbSettings));
1958
+ migrated = true;
1566
1959
  }
1567
- delete isolationObj.enabled;
1960
+ dbReadSucceeded = true;
1961
+ } catch (error) {
1962
+ // A malformed or persistently unreadable legacy database is a data
1963
+ // integrity problem: fail the load so it is actionable instead of
1964
+ // silently continuing without the previously effective rows. The
1965
+ // rows did NOT participate in any publication, so nothing is
1966
+ // drained and they stay in place for repair and the next load.
1967
+ this.#warnLegacyFallbackMigration(
1968
+ `Settings: legacy agent.db settings could not be read (${error instanceof Error ? error.message : String(error)}); failing the load so the database can be repaired`,
1969
+ );
1970
+ throw error;
1568
1971
  }
1569
1972
 
1570
- // task.isolation.mode: legacy values from before the pi-iso PAL refactor.
1571
- // `worktree` was git worktree → now lives under `rcopy`. `fuse-overlay`
1572
- // and `fuse-projfs` are now the platform-named `overlayfs` / `projfs`
1573
- // kinds; the PAL falls back internally when the chosen one isn't
1574
- // available, so we don't need the old TS-side platform guards.
1575
- if (isolationObj && typeof isolationObj.mode === "string") {
1576
- const legacy: Record<string, string> = {
1577
- worktree: "rcopy",
1578
- "fuse-overlay": "overlayfs",
1579
- "fuse-projfs": "projfs",
1580
- };
1581
- const mapped = legacy[isolationObj.mode as string];
1582
- if (mapped !== undefined) {
1583
- isolationObj.mode = mapped;
1973
+ // 3. Write merged settings through the shared atomic YAML pipeline. When
1974
+ // config.yml already exists (created by the config-root workflow migration,
1975
+ // or a drained-then-reopened database), merge ABSENT-ONLY so the existing
1976
+ // values - including the workflow values the config-root migration wrote -
1977
+ // are never clobbered.
1978
+ if (
1979
+ migrated &&
1980
+ (settingsJsonRetirementPending === false || settingsJsonMarkerPersisted) &&
1981
+ Object.keys(settings).length > 0
1982
+ ) {
1983
+ // Publication is best-effort: a write failure (e.g. a CAS conflict with
1984
+ // an external config.yml edit) keeps the legacy rows for the next load
1985
+ // to retry and must never fail the settings load.
1986
+ let published = false;
1987
+ try {
1988
+ // Publish under the config's own transaction so the absent-only
1989
+ // merge runs against the CURRENT durable state and every write is
1990
+ // CAS-guarded: an external editor that replaced config.yml between
1991
+ // the read and the rename must not have its newer file (and any
1992
+ // unrelated settings it carries) silently overwritten. The same
1993
+ // absent-only collection handles both the present and the absent
1994
+ // target (an absent target starts from an empty current record).
1995
+ // Read-only targets are rejected BEFORE any merge, mirroring the
1996
+ // workflow migrations' guards: a scalar/array/null root is malformed
1997
+ // user data (patching would replace the whole document) and a FUTURE
1998
+ // configSchemaVersion is never modified - in both cases the legacy
1999
+ // rows stay for the next load instead of being drained.
2000
+ published = await withAtomicYamlConfigTransaction(this.#configPath, async tx => {
2001
+ const root = tx.root;
2002
+ if (root !== undefined && (root === null || typeof root !== "object" || Array.isArray(root))) {
2003
+ this.#warnLegacyFallbackMigration(
2004
+ `Settings: database workflow migration skipped: ${this.#configPath} has a non-object or null YAML root; leaving legacy rows for the next load`,
2005
+ );
2006
+ return false;
2007
+ }
2008
+ const schemaVersion = (root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
2009
+ if (typeof schemaVersion === "number" && schemaVersion > CONFIG_SCHEMA_VERSION) {
2010
+ this.#warnLegacyFallbackMigration(
2011
+ `Settings: database workflow migration skipped: ${this.#configPath} is a future config schema (configSchemaVersion ${schemaVersion} > ${CONFIG_SCHEMA_VERSION}); leaving legacy rows for the next load`,
2012
+ );
2013
+ return false;
2014
+ }
2015
+ const patches = this.#collectAbsentLegacyPatches(tx.current, settings);
2016
+ // The interrupted-retirement recovery (a pending marker): the
2017
+ // source's CURRENT values are republished as SET patches (not
2018
+ // absent-only) so a concurrent edit after the interrupted run is
2019
+ // preserved instead of staying permanently ignored under the
2020
+ // target's older value.
2021
+ // The interrupted-retirement recovery (a pending marker): the
2022
+ // source's CURRENT values are republished as SET patches (not
2023
+ // absent-only) so a PROVEN post-marker source edit is preserved
2024
+ // instead of staying permanently ignored under the target's older
2025
+ // value. The source hash must DIFFER from the marker's recorded
2026
+ // hash: an unchanged source means the target holds the user's
2027
+ // current surface edits and must NOT be reverted by the stale
2028
+ // source values. Patch paths mirror #collectAbsentLegacyPatches
2029
+ // (top-level keys split on dots; keys inside records are literal
2030
+ // segments and are never split).
2031
+ const sourceSetPatches =
2032
+ settingsJsonRetirementMarkerExists && settingsJsonMigrated !== null
2033
+ ? await (async (): Promise<AtomicYamlPatch[]> => {
2034
+ const currentSha =
2035
+ settingsJsonRaw !== null
2036
+ ? nodeCrypto.createHash("sha256").update(settingsJsonRaw).digest("hex")
2037
+ : null;
2038
+ // A PROVEN post-marker source edit: the source hash
2039
+ // differs from the INTERRUPTED run's recorded revision
2040
+ // (the marker's entry-time sha). An unchanged source
2041
+ // means the target holds the user's current surface
2042
+ // edits and must NOT be reverted by the stale source
2043
+ // values.
2044
+ if (
2045
+ settingsJsonMarkerShaAtEntry === null ||
2046
+ currentSha === null ||
2047
+ currentSha === settingsJsonMarkerShaAtEntry
2048
+ ) {
2049
+ return [];
2050
+ }
2051
+ return this.#collectSourceOverwritePatches(settingsJsonMigrated);
2052
+ })()
2053
+ : [];
2054
+ const allPatches = [...sourceSetPatches, ...patches];
2055
+ if (allPatches.length > 0) {
2056
+ await tx.applyPatches(allPatches);
2057
+ }
2058
+ return true;
2059
+ });
2060
+ } catch {}
2061
+ if (!published) return;
2062
+ if (!dbReadSucceeded) {
2063
+ // The database rows never participated in the publication (the read
2064
+ // failed): draining would delete rows that were not merged into
2065
+ // config.yml. Keep them for the next load, which retries the read.
2066
+ this.#warnLegacyFallbackMigration(
2067
+ `Settings: legacy database settings could not be read; keeping the rows for the next load`,
2068
+ );
2069
+ return;
1584
2070
  }
2071
+ // Clear the drained database rows so a later load never re-imports
2072
+ // them (the database has no other one-time guard). A persistent drain
2073
+ // failure PROPAGATES instead of being converted into a successful
2074
+ // migration: the stale rows stay eligible for the absent-only merge, so
2075
+ // a later `gjc config unset` of a migrated key would be resurrected on
2076
+ // the next load. The migration aborts (the settings load fails loudly)
2077
+ // and the next load retries the drain.
2078
+ await this.#storage?.clearSettings();
2079
+ logger.debug("Settings: migrated to config.yml", { path: this.#configPath });
1585
2080
  }
1586
-
1587
- // edit.mode: removed "atom" variant is now "hashline"
1588
- const editObj = raw.edit as Record<string, unknown> | undefined;
1589
- if (editObj) {
1590
- if (editObj.mode === "atom") {
1591
- editObj.mode = "hashline";
1592
- }
1593
- const modelVariants = editObj.modelVariants as Record<string, unknown> | undefined;
1594
- if (modelVariants && typeof modelVariants === "object" && !Array.isArray(modelVariants)) {
1595
- for (const [pattern, variant] of Object.entries(modelVariants)) {
1596
- if (variant === "atom") {
1597
- modelVariants[pattern] = "hashline";
1598
- }
2081
+ // The combined migration committed (database validated and the merged
2082
+ // values published): retire the settings.json source now. Any earlier
2083
+ // failure path (malformed database row, unreadable marker, drain
2084
+ // failure) returns above with the source still in place for the next
2085
+ // load.
2086
+ if (settingsJsonRetirementPending || (await this.#pathExists(`${settingsJsonPath}.pending-retirement`))) {
2087
+ // Revalidate the source BEFORE retiring it: an edit or replacement
2088
+ // after the bytes were read but while this method awaited database
2089
+ // retries, the config transaction, or the drain must NOT be moved to
2090
+ // the inactive .bak - the user's newer settings would be stranded
2091
+ // while config.yml holds the earlier bytes. A changed source stays
2092
+ // active for the next load to re-read.
2093
+ const expectedSha =
2094
+ settingsJsonRaw !== null
2095
+ ? nodeCrypto.createHash("sha256").update(settingsJsonRaw).digest("hex")
2096
+ : await Bun.file(`${settingsJsonPath}.pending-retirement`)
2097
+ .text()
2098
+ .catch(() => null);
2099
+ const currentRaw = await Bun.file(settingsJsonPath)
2100
+ .text()
2101
+ .catch(() => null);
2102
+ // PUBLICATION PROOF: the marker alone is not proof that the merged
2103
+ // values were ever written - the publication may have been skipped or
2104
+ // conflicted (e.g. another writer created a future-schema config.yml)
2105
+ // after the marker was persisted. Every workflow key present in the
2106
+ // source must exist in the target config.yml before the source is
2107
+ // retired.
2108
+ let publicationProof = false;
2109
+ if (currentRaw !== null) {
2110
+ try {
2111
+ const targetRaw = await Bun.file(this.#configPath)
2112
+ .text()
2113
+ .catch(() => "");
2114
+ const targetRoot = (YAML.parse(targetRaw) ?? {}) as Record<string, unknown> | null;
2115
+ // Verify EVERY migrated path, not just the workflow keys: the
2116
+ // agent-dir migration merges the entire #migrateRawSettings
2117
+ // result (non-workflow settings included) through
2118
+ // #collectAbsentLegacyPatches, so recovery must prove every
2119
+ // post-migration path (including renamed paths) exists in the
2120
+ // target before retiring the source. The merged `settings`
2121
+ // holds the same paths the publication wrote.
2122
+ const migratedPaths = flattenObjectPaths(settings);
2123
+ publicationProof =
2124
+ targetRoot !== null &&
2125
+ typeof targetRoot === "object" &&
2126
+ migratedPaths.length > 0 &&
2127
+ migratedPaths.every(path => hasPathValue(targetRoot, path));
2128
+ } catch {
2129
+ publicationProof = false;
1599
2130
  }
1600
2131
  }
1601
- }
1602
- if (raw["edit.mode"] === "atom") {
1603
- raw["edit.mode"] = "hashline";
1604
- }
1605
-
1606
- // statusLine: rename "plan_mode" segment to "mode"
1607
- const statusLineObj = raw.statusLine as Record<string, unknown> | undefined;
1608
- if (statusLineObj) {
1609
- for (const key of ["leftSegments", "rightSegments"] as const) {
1610
- const segments = statusLineObj[key];
1611
- if (Array.isArray(segments)) {
1612
- statusLineObj[key] = segments.map(seg => (seg === "plan_mode" ? "mode" : seg));
2132
+ let retired = false;
2133
+ if (
2134
+ expectedSha !== null &&
2135
+ currentRaw !== null &&
2136
+ nodeCrypto.createHash("sha256").update(currentRaw).digest("hex") === expectedSha &&
2137
+ publicationProof
2138
+ ) {
2139
+ // Identity-guard the rename: only move the exact inode whose bytes
2140
+ // were validated (captured from the same descriptor as the read). A
2141
+ // replacement at any point after the validated read must stay active
2142
+ // (its newer settings are re-migrated by the next load via the
2143
+ // marker gate).
2144
+ const currentIno = await fs.promises
2145
+ .lstat(settingsJsonPath)
2146
+ .then(stat => stat.ino)
2147
+ .catch(() => null);
2148
+ if (settingsJsonIno !== null && settingsJsonIno === currentIno) {
2149
+ try {
2150
+ fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`);
2151
+ retired = true;
2152
+ } catch {}
1613
2153
  }
1614
2154
  }
1615
- const segmentOptions = statusLineObj.segmentOptions as Record<string, unknown> | undefined;
1616
- if (segmentOptions && "plan_mode" in segmentOptions && !("mode" in segmentOptions)) {
1617
- segmentOptions.mode = segmentOptions.plan_mode;
1618
- delete segmentOptions.plan_mode;
2155
+ // The pending-retirement marker is consumed ONLY after a successful
2156
+ // retirement: a failed rename (Windows sharing violation, permissions)
2157
+ // must leave the marker so the next load retries instead of stranding
2158
+ // an active-looking legacy file with no retry prompt.
2159
+ if (retired) {
2160
+ try {
2161
+ await fs.promises.rm(`${settingsJsonPath}.pending-retirement`, { force: true });
2162
+ } catch {}
1619
2163
  }
1620
2164
  }
2165
+ }
1621
2166
 
1622
- // Map legacy `memories.enabled` boolean to the explicit `memory.backend`
1623
- // enum if the latter hasn't been set yet. Idempotent: subsequent
1624
- // migrations are no-ops once memory.backend is materialised.
1625
- const memoryBackendObj = raw.memory as Record<string, unknown> | undefined;
1626
- const memoryBackendSet = memoryBackendObj && typeof memoryBackendObj.backend === "string";
1627
- const memoriesObj = raw.memories as Record<string, unknown> | undefined;
1628
- if (!memoryBackendSet && memoriesObj && typeof memoriesObj.enabled === "boolean") {
1629
- const next = memoriesObj.enabled ? "local" : "off";
1630
- const memoryRoot = (memoryBackendObj ?? {}) as Record<string, unknown>;
1631
- memoryRoot.backend = next;
1632
- raw.memory = memoryRoot;
2167
+ /**
2168
+ * One-time migration of the machine-global config-root `settings.json`
2169
+ * (`<configRoot>/settings.json`, normally `~/.gjc/settings.json`) workflow
2170
+ * keys into the environment-selected global agent `config.yml` — the
2171
+ * `GJC_CODING_AGENT_DIR` / `PI_CODING_AGENT_DIR` profile when set, else the
2172
+ * default `<configRoot>/agent/config.yml`. Runs only for the global agent
2173
+ * scope (an explicitly supplied temporary agentDir such as an SDK session
2174
+ * must never consume the machine-global source), inside one critical
2175
+ * section on the target config lock, and migrates only the five workflow
2176
+ * keys that the workflow runtimes read.
2177
+ *
2178
+ * The legacy config-root file is an orphan path: only the workflow runtimes
2179
+ * ever read it, and the earlier Settings migrations never covered it. Keeping
2180
+ * it read-only forever would leave two settings surfaces in conflict, so a
2181
+ * valid source is consumed exactly once (absent-only patches, no-clobber
2182
+ * `.bak`, durable sidecar marker) after which the runtimes' legacy fallback
2183
+ * still works for a user-recreated file.
2184
+ */
2185
+ async #migrateConfigRootWorkflowSettings(): Promise<void> {
2186
+ if (!this.#configPath) return;
2187
+ // Strengthened pairing gate: only the GLOBAL agent scope may consume the
2188
+ // machine-global source. That includes an environment-selected non-default
2189
+ // profile (GJC_CODING_AGENT_DIR / PI_CODING_AGENT_DIR); a custom/temporary
2190
+ // agentDir (`Settings.loadForScope` for SDK or tests) must never touch it.
2191
+ if (!this.#isGlobalAgentScope()) return;
2192
+
2193
+ const source = path.resolve(getConfigRootDir(), "settings.json");
2194
+ const backup = `${source}.bak`;
2195
+ const markerPath = `${source}.migrated`;
2196
+ const target = path.resolve(this.#configPath);
2197
+ // If the config root is literally the agent dir, the agent-dir migration
2198
+ // already owns this physical source; never double-rename it. When that
2199
+ // migration has ALREADY run (the agent config.yml exists) it leaves the
2200
+ // orphan source's workflow keys unmigrated, so this migration must still
2201
+ // copy them (and retire the orphan).
2202
+ if (source === path.resolve(path.join(this.#agentDir, "settings.json"))) {
2203
+ if (!(await this.#pathExists(this.#configPath))) return;
2204
+ this.#warnLegacyFallbackMigration(
2205
+ `Settings: config-root workflow migration source ${source} is the agent-dir settings file with an existing agent config.yml; migrating the workflow keys and retiring the orphan source`,
2206
+ );
1633
2207
  }
1634
-
2208
+ // When GJC runs from the config root itself (typically the user's home
2209
+ // with the default `.gjc`), the config-root source IS the project source
2210
+ // (`<cwd>/.gjc/settings.json`): consuming and retiring it would delete the
2211
+ // project's non-workflow settings from discovery (and could remove a
2212
+ // tracked dotfiles copy). Defer entirely to the project migration, which
2213
+ // keeps the source - but first reconcile the config-root sidecars against
2214
+ // the agent target, which the project migration never touches.
2215
+ if (await this.#configRootCollidesWithProjectSource(source)) {
2216
+ this.#warnLegacyFallbackMigration(
2217
+ `Settings: config-root workflow migration skipped: ${source} is also the project settings source; the project migration handles it and the file is preserved`,
2218
+ );
2219
+ await this.#reconcileConfigRootSidecars(source, target);
2220
+ return;
2221
+ }
2222
+
2223
+ // Strict-invalid retention evidence is valid only while the source still
2224
+ // holds the exact invalid value that caused the abort. A repair, a
2225
+ // removal, or a completed migration must clear it so strict resolution
2226
+ // stops throwing; a still-invalid value is re-recorded below by the same
2227
+ // run's abort.
2228
+ await this.#reconcileConfigRootSidecars(source, target);
2229
+
2230
+ // Short-circuit before touching the target config.yml: with no source,
2231
+ // backup, or marker there is nothing to migrate, and entering the
2232
+ // transaction would parse the target (aborting settings load on a
2233
+ // malformed config.yml even when no migration is needed).
2234
+ const preSourceExists = await this.#pathExists(source);
2235
+ const preBackupExists = await this.#pathExists(backup);
2236
+ const preMarkerExists = await this.#pathExists(markerPath);
2237
+ if (!preSourceExists && !preBackupExists && !preMarkerExists) return;
2238
+ // Tracks whether THIS run durably wrote the pending marker and whether its
2239
+ // target patch committed. A CAS rejection with a pending marker written by
2240
+ // this run but no committed target write must clear the marker (its
2241
+ // migratedKeys claim ownership of never-applied patches); a prior run's
2242
+ // marker is retained as the only evidence its values are migration-written.
2243
+ let pendingMarkerWritten = false;
2244
+ let targetPatchCommitted = false;
2245
+ // A `.bak` created by ANOTHER process after the initial backupExists check
2246
+ // must never be removed by this migration: abort paths may delete a backup
2247
+ // only when this run created it (after a successful no-replace move).
2248
+ let backupCreatedByThisRun = false;
2249
+ // Identity of the backup this run created (captured right after the
2250
+ // no-replace move): abort paths remove the backup only through the
2251
+ // quarantine-based #removeIfStillOurs guard, never by pathname.
2252
+ let backupIno: number | null = null;
2253
+ try {
2254
+ await withAtomicYamlConfigTransaction(target, async tx => {
2255
+ // A config.yml written by a NEWER schema version is intentionally
2256
+ // read-only across Settings; the migration runs before #loadYaml
2257
+ // sets #futureSchemaVersion, so it must check the target schema
2258
+ // itself and never patch it or consume the legacy source.
2259
+ const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
2260
+ if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) {
2261
+ this.#warnLegacyFallbackMigration(
2262
+ `Settings: config-root workflow migration skipped: ${target} is a future config schema (configSchemaVersion ${targetSchemaVersion} > ${CONFIG_SCHEMA_VERSION})`,
2263
+ );
2264
+ // A future-schema target is read-only, but the retained legacy
2265
+ // source's strict errors must stay observable: the resolver reads
2266
+ // config.yml only, so record strict-invalid evidence when the
2267
+ // source holds invalid ralplan values or is malformed.
2268
+ await this.#retainStrictErrorsFromSource(source, `${source}.strict-invalid`);
2269
+ return;
2270
+ }
2271
+ const markerFileExists = await this.#pathExists(markerPath);
2272
+ let marker = await this.#readWorkflowMigrationMarker(markerPath);
2273
+ // A structurally valid marker that points at different source/backup/
2274
+ // target paths (e.g. the config root moved) must never suppress or
2275
+ // shortcut the migration; treat it as invalid.
2276
+ if (marker && !this.#workflowMigrationMarkerPathsMatch(marker, source, backup, target)) {
2277
+ this.#warnLegacyFallbackMigration(
2278
+ `Settings: config-root workflow migration marker at ${markerPath} does not match the current source/backup/target paths; treating it as invalid`,
2279
+ );
2280
+ marker = null;
2281
+ }
2282
+ // The marker records the identity of the directory that received (or
2283
+ // would receive) the migration write. If that directory was
2284
+ // deleted/recreated or a symlink was repointed, recovery must not
2285
+ // apply the marker's ownership claims to the replacement profile -
2286
+ // for a PENDING marker (claims never completed) and for a COMPLETE
2287
+ // marker (deletion recovery or reconcile would otherwise overwrite
2288
+ // or unset a genuine value in the new profile).
2289
+ if (marker?.status === "pending") {
2290
+ const pendingMarkerIdentity = marker.canonicalTargetIdentity;
2291
+ if (
2292
+ typeof pendingMarkerIdentity !== "string" ||
2293
+ pendingMarkerIdentity.length === 0 ||
2294
+ (await this.#statIdentity(path.dirname(target))) !== pendingMarkerIdentity
2295
+ ) {
2296
+ this.#warnLegacyFallbackMigration(
2297
+ `Settings: config-root workflow migration pending marker at ${markerPath} lacks or mismatches the target directory identity; treating it as invalid so recovery never applies its claims to the current profile`,
2298
+ );
2299
+ marker = null;
2300
+ }
2301
+ } else if (marker?.status === "complete") {
2302
+ const completeMarkerIdentity = marker.canonicalTargetIdentity;
2303
+ if (
2304
+ typeof completeMarkerIdentity !== "string" ||
2305
+ completeMarkerIdentity.length === 0 ||
2306
+ (await this.#statIdentity(path.dirname(target))) !== completeMarkerIdentity
2307
+ ) {
2308
+ this.#warnLegacyFallbackMigration(
2309
+ `Settings: config-root workflow migration complete marker at ${markerPath} lacks or mismatches the target directory identity; treating it as invalid so recovery never applies its claims to the current profile`,
2310
+ );
2311
+ marker = null;
2312
+ }
2313
+ }
2314
+ if (marker?.status === "complete") {
2315
+ // The legacy source is RETIRED once the migration completes:
2316
+ // config.yml is the only settings surface and the resolver never
2317
+ // reads settings.json. A crash between the complete-marker
2318
+ // publication and the source quarantine leaves the legacy file
2319
+ // behind, where later user edits are silently ineffective and the
2320
+ // documented cleanup never completes; retire the surviving
2321
+ // revision idempotently here (only bytes matching the marker's
2322
+ // recorded hash, so a concurrent replacement is never deleted). A
2323
+ // POST-COMPLETION RECREATION is preserved: a user who unsets a
2324
+ // migrated key and restores settings.json from the .bak produces a
2325
+ // byte-identical file, so retire only while the target still
2326
+ // satisfies the completed marker's migrated keys.
2327
+ if (
2328
+ (await this.#pathExists(source)) &&
2329
+ (await this.#workflowMigrationTargetSatisfies(tx.root, marker))
2330
+ ) {
2331
+ await this.#retireConfigRootSource(source, marker.sourceSha256);
2332
+ }
2333
+ return;
2334
+ }
2335
+
2336
+ const sourceExists = await this.#pathExists(source);
2337
+ const backupExists = await this.#pathExists(backup);
2338
+
2339
+ // Valid pending marker: crash-recovery proof only.
2340
+ if (marker?.status === "pending") {
2341
+ if (backupExists && !sourceExists) {
2342
+ // The copy path NEVER removes the source, so its absence
2343
+ // here is an external DELETION: honor it by reverting the
2344
+ // marker-owned target values, removing the backup, and
2345
+ // clearing the marker - instead of finalizing and silently
2346
+ // restoring the deleted overrides.
2347
+ const backupHash = await this.#sha256File(backup);
2348
+ if (backupHash !== marker.sourceSha256 && backupHash !== marker.priorSourceSha256) {
2349
+ this.#warnLegacyFallbackMigration(
2350
+ `Settings: config-root workflow migration pending marker cannot be verified (${backup}); leaving for diagnosis/retry`,
2351
+ );
2352
+ return;
2353
+ }
2354
+ // A deletion during a reconcile transition (the backup still
2355
+ // matches priorSourceSha256) must revert EVERY marker-owned
2356
+ // target - the marker claims them as its repairs. In the
2357
+ // fresh case (backup matches the marker hash) revert only
2358
+ // the values still matching the migration write, preserving
2359
+ // a newer `gjc config set` override.
2360
+ // A deletion during a reconcile transition accepts the
2361
+ // prior-hash backup, but only targets matching a verifiable
2362
+ // migration write (the backup) are reverted: a target the
2363
+ // user replaced after the transition cannot be verified
2364
+ // (the repaired values' source is gone) and is preserved.
2365
+ let deletionBackupDoc: Record<string, unknown> | null = null;
2366
+ // Identity of the verified backup revision (captured inside
2367
+ // the try): the removal below must quarantine and re-verify
2368
+ // before unlinking, because another process can replace the
2369
+ // backup while the recovery patches the target.
2370
+ let deletionBackupIno: number | null = null;
2371
+ // Verified revision hash (captured inside the try); used by
2372
+ // the guarded removal below.
2373
+ let deletionBackupHash: string | null = null;
2374
+ try {
2375
+ const deletionBackupRead = await this.#readBackupBytes(backup);
2376
+ deletionBackupHash = nodeCrypto
2377
+ .createHash("sha256")
2378
+ .update(Buffer.from(deletionBackupRead.bytes))
2379
+ .digest("hex");
2380
+ if (
2381
+ deletionBackupHash !== marker.sourceSha256 &&
2382
+ deletionBackupHash !== marker.priorSourceSha256
2383
+ ) {
2384
+ this.#warnLegacyFallbackMigration(
2385
+ `Settings: the migration backup ${backup} no longer matches the marker hash; leaving source/backup/marker untouched`,
2386
+ );
2387
+ return;
2388
+ }
2389
+ deletionBackupIno = (await fs.promises.stat(backup).catch(() => null))?.ino ?? null;
2390
+ deletionBackupDoc = JSON.parse(deletionBackupRead.text) as Record<string, unknown>;
2391
+ } catch {
2392
+ this.#warnLegacyFallbackMigration(
2393
+ `Settings: could not read the migration backup ${backup} for deletion recovery; leaving source/backup/marker untouched`,
2394
+ );
2395
+ return;
2396
+ }
2397
+ const markerOwnedUnsets: AtomicYamlPatch[] = [];
2398
+ const markerFlatKeys: string[] = [];
2399
+ for (const key of marker.migratedKeys) {
2400
+ const targetValue = extractWorkflowSetting(tx.root, key, { flat: false });
2401
+ if (!targetValue.present) continue;
2402
+ const migratedValue = extractWorkflowSetting(deletionBackupDoc, key);
2403
+ if (
2404
+ (migratedValue.present &&
2405
+ this.#coerceWorkflowScalar(key, migratedValue.value) === targetValue.value) ||
2406
+ // A reconcile that COMMITTED its repairs left the recorded
2407
+ // repair values in the target: the deletion must revert
2408
+ // them too (they are migration writes, not user
2409
+ // overrides). The repair is committed only when the
2410
+ // post-apply flag is set (a mere change from the
2411
+ // pre-repair state could be a coincidental user
2412
+ // value).
2413
+ (marker.repairValueHashes?.[key] !== undefined &&
2414
+ nodeCrypto.createHash("sha256").update(JSON.stringify(targetValue.value)).digest("hex") ===
2415
+ marker.repairValueHashes[key] &&
2416
+ marker.repairsApplied === true)
2417
+ ) {
2418
+ markerOwnedUnsets.push({ path: key, op: "unset" });
2419
+ if (Object.hasOwn(tx.root as Record<string, unknown>, key)) markerFlatKeys.push(key);
2420
+ }
2421
+ }
2422
+ await tx.applyPatchesAndRemoveTopLevelKeys(markerOwnedUnsets, markerFlatKeys);
2423
+ if (deletionBackupIno !== null && deletionBackupHash !== null) {
2424
+ await this.#removeIfStillOurs(backup, deletionBackupIno, deletionBackupHash);
2425
+ }
2426
+ await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
2427
+ this.#warnLegacyFallbackMigration(
2428
+ `Settings: config-root workflow migration cleared: ${source} was deleted during pending recovery; marker-owned target values reverted, backup removed`,
2429
+ );
2430
+ return;
2431
+ }
2432
+ if (sourceExists && backupExists) {
2433
+ const sourceStat = await fs.promises.stat(source).catch((error: unknown) => {
2434
+ // Only ENOENT means absence; a transient permission/I-O
2435
+ // failure must not be misread as a source edit (which
2436
+ // would revert marker-owned values and remove recovery
2437
+ // artifacts).
2438
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
2439
+ throw error;
2440
+ });
2441
+ const sourceHash = sourceStat ? await this.#sha256File(source) : "";
2442
+ // Read the backup ONCE: the hash below and the edited-source
2443
+ // parse use the SAME bytes (a second read could observe a
2444
+ // different revision).
2445
+ const pendingBackupRead = await this.#readBackupBytes(backup);
2446
+ const backupHash = nodeCrypto
2447
+ .createHash("sha256")
2448
+ .update(Buffer.from(pendingBackupRead.bytes))
2449
+ .digest("hex");
2450
+ // Capture the identity of the verified backup revision so the
2451
+ // recovery removal below can quarantine and re-verify before
2452
+ // unlinking (another process can replace the backup while
2453
+ // the target patches are applied).
2454
+ const pendingBackupIno = (await fs.promises.stat(backup).catch(() => null))?.ino ?? null;
2455
+ // (Reconciliation of an edited legacy source after completion is
2456
+ // obsolete: settings.json is retired, so a hash mismatch simply
2457
+ // falls through to the mismatch branch below.)
2458
+ if (sourceHash === marker.sourceSha256 && backupHash === marker.sourceSha256) {
2459
+ // Interrupted no-replace move with the target already patched:
2460
+ // the duplicate source is kept ACTIVE - a path-based unlink
2461
+ // after the identity check could delete a rename-replaced
2462
+ // file - and the resolver deactivates the migrated legacy
2463
+ // layer while the source still matches the marker hash
2464
+ // (reactivating it on later edits/recreates). Complete only
2465
+ // when the target actually contains the migrated keys.
2466
+ if (this.#workflowMigrationTargetSatisfies(tx.root, marker)) {
2467
+ const targetIdentity = await this.#workflowMigrationTargetIdentity(target);
2468
+ if (targetIdentity === null) {
2469
+ this.#warnLegacyFallbackMigration(
2470
+ `Settings: config-root workflow migration cannot publish completion because the target directory identity is unavailable; leaving source, backup, and marker pending`,
2471
+ );
2472
+ } else if (
2473
+ typeof marker.targetFileIdentity === "string" &&
2474
+ (await this.#targetFileIdentity(target)) !== marker.targetFileIdentity
2475
+ ) {
2476
+ // The marker recorded the FILE that received the migration
2477
+ // write; a replaced file is a genuine override, not a
2478
+ // migration write - never complete behind it.
2479
+ this.#warnLegacyFallbackMigration(
2480
+ `Settings: config-root workflow migration target file ${target} was replaced after the migration write; leaving source, backup, and marker pending`,
2481
+ );
2482
+ } else {
2483
+ await this.#writeWorkflowMigrationMarkerAtomic(markerPath, {
2484
+ ...marker,
2485
+ status: "complete",
2486
+ priorSourceSha256: undefined,
2487
+ repairValueHashes: undefined,
2488
+ repairsApplied: undefined,
2489
+ preRepairTargetHashes: undefined,
2490
+ ...targetIdentity,
2491
+ completedAt: new Date().toISOString(),
2492
+ });
2493
+ }
2494
+ } else {
2495
+ this.#warnLegacyFallbackMigration(
2496
+ `Settings: config-root workflow migration pending marker has matching source/backup but the target lacks the migrated keys; leaving source and backup untouched`,
2497
+ );
2498
+ }
2499
+ } else {
2500
+ if (backupHash === marker.sourceSha256 && sourceHash !== marker.sourceSha256) {
2501
+ // The user EDITED the still-active source after the
2502
+ // crash: revert ONLY the marker-owned target values
2503
+ // that still match the migration's write (the backup
2504
+ // copy); a newer `gjc config set` override is
2505
+ // preserved. Remove the backup and the pending marker
2506
+ // so the next load re-runs fresh against the edited
2507
+ // source.
2508
+ let editBackupDoc: Record<string, unknown> | null = null;
2509
+ try {
2510
+ // Parse the bytes already read and verified above
2511
+ // (the branch requires backupHash ===
2512
+ // marker.sourceSha256, a subset of the check here).
2513
+ editBackupDoc = JSON.parse(pendingBackupRead.text) as Record<string, unknown>;
2514
+ } catch {
2515
+ this.#warnLegacyFallbackMigration(
2516
+ `Settings: could not read the migration backup ${backup} for edited-source recovery; leaving source/backup/marker untouched`,
2517
+ );
2518
+ return;
2519
+ }
2520
+ const markerOwnedUnsets: AtomicYamlPatch[] = [];
2521
+ const markerFlatKeys: string[] = [];
2522
+ for (const key of marker.migratedKeys) {
2523
+ const targetValue = extractWorkflowSetting(tx.root, key, { flat: false });
2524
+ const migratedValue = extractWorkflowSetting(editBackupDoc, key);
2525
+ if (
2526
+ targetValue.present &&
2527
+ migratedValue.present &&
2528
+ this.#coerceWorkflowScalar(key, migratedValue.value) === targetValue.value
2529
+ ) {
2530
+ markerOwnedUnsets.push({ path: key, op: "unset" });
2531
+ if (Object.hasOwn(tx.root as Record<string, unknown>, key)) markerFlatKeys.push(key);
2532
+ }
2533
+ }
2534
+ await tx.applyPatchesAndRemoveTopLevelKeys(markerOwnedUnsets, markerFlatKeys);
2535
+ if (pendingBackupIno !== null) {
2536
+ await this.#removeIfStillOurs(backup, pendingBackupIno, backupHash);
2537
+ }
2538
+ await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
2539
+ this.#warnLegacyFallbackMigration(
2540
+ `Settings: config-root workflow migration pending marker source edited after a crash; stale marker-owned target values reverted, user overrides kept, backup removed, marker cleared`,
2541
+ );
2542
+ } else {
2543
+ this.#warnLegacyFallbackMigration(
2544
+ `Settings: config-root workflow migration pending marker has both source and backup with mismatched hashes; leaving untouched`,
2545
+ );
2546
+ }
2547
+ }
2548
+ return;
2549
+ }
2550
+ if (!sourceExists && !backupExists) {
2551
+ this.#warnLegacyFallbackMigration(
2552
+ `Settings: config-root workflow migration pending marker without source or backup; leaving for diagnosis`,
2553
+ );
2554
+ return;
2555
+ }
2556
+ // pending | yes | no — fall through and re-run the idempotent fresh
2557
+ // transaction (absent-only patches make re-application harmless).
2558
+ } else if (sourceExists && backupExists) {
2559
+ // Absent/invalid marker with a pre-existing backup is ambiguous;
2560
+ // never consume or overwrite either file.
2561
+ this.#warnLegacyFallbackMigration(
2562
+ `Settings: config-root workflow migration found pre-existing ${backup} without a valid marker; leaving source and backup untouched`,
2563
+ );
2564
+ return;
2565
+ } else if (!sourceExists && backupExists) {
2566
+ // Orphan backup: values may already be in the target; keep both
2567
+ // recoverable and never infer completion from the backup alone.
2568
+ this.#warnLegacyFallbackMigration(
2569
+ `Settings: config-root workflow migration found orphan backup ${backup} without a marker; leaving it untouched`,
2570
+ );
2571
+ return;
2572
+ } else if (!sourceExists && !backupExists) {
2573
+ return;
2574
+ }
2575
+
2576
+ // Fresh transaction (or pending | yes | no re-run): source exists,
2577
+ // backup absent. All steps run under the target config lock.
2578
+ let sourceRaw: string;
2579
+ try {
2580
+ sourceRaw = await Bun.file(source).text();
2581
+ } catch (error) {
2582
+ // An EXISTING but unreadable source (EACCES, EIO) must keep the
2583
+ // strict error observable: record malformed evidence so the
2584
+ // direct command fails loudly instead of silently defaulting.
2585
+ if (!isEnoent(error)) {
2586
+ await this.#recordConfigRootMalformedEvidence(tx, source);
2587
+ }
2588
+ this.#warnLegacyFallbackMigration(
2589
+ `Settings: config-root workflow migration could not read ${source}; leaving untouched`,
2590
+ );
2591
+ return;
2592
+ }
2593
+ const sourceSha256 = nodeCrypto.createHash("sha256").update(sourceRaw).digest("hex");
2594
+ let sourceDoc: unknown;
2595
+ try {
2596
+ sourceDoc = JSON.parse(sourceRaw);
2597
+ } catch {
2598
+ if (await this.#configRootSourceMatches(source, sourceSha256)) {
2599
+ await this.#recordConfigRootMalformedEvidence(tx, source);
2600
+ }
2601
+ this.#warnLegacyFallbackMigration(
2602
+ `Settings: config-root workflow migration found malformed JSON in ${source}; leaving source/backup/marker unchanged`,
2603
+ );
2604
+ return;
2605
+ }
2606
+ // A `null` document root is malformed per the strict resolver (exit
2607
+ // 2); the migration must not consume it (empty keys + .bak +
2608
+ // complete marker would silently default). Leave the source active
2609
+ // so the strict failure stays loud.
2610
+ if (
2611
+ sourceDoc === null ||
2612
+ sourceDoc === undefined ||
2613
+ typeof sourceDoc !== "object" ||
2614
+ Array.isArray(sourceDoc)
2615
+ ) {
2616
+ // A `null`/non-object root is malformed per the strict resolver
2617
+ // (exit 2); the migration must not consume it (empty keys +
2618
+ // .bak + complete marker would silently default). Under a
2619
+ // changed-pending recovery (crash after the patch, before the
2620
+ // backup), clear the stale marker-owned target patches so the
2621
+ // malformed source is visible to strict ralplan instead of
2622
+ // being shadowed by the old agent value.
2623
+ // Only clear patches when a backup verifies they are still the
2624
+ // migration write; without one the target values may be newer
2625
+ // overrides and must be preserved.
2626
+ if (marker?.status === "pending" && backupExists) {
2627
+ // A malformed source cannot establish ownership of the target
2628
+ // values. Verify and parse the backup bytes that the marker records
2629
+ // before clearing anything; a post-crash user override must survive.
2630
+ let staleBackupDoc: Record<string, unknown>;
2631
+ try {
2632
+ const staleBackupRead = await this.#readBackupBytes(backup);
2633
+ const staleBackupHash = nodeCrypto
2634
+ .createHash("sha256")
2635
+ .update(Buffer.from(staleBackupRead.bytes))
2636
+ .digest("hex");
2637
+ if (staleBackupHash !== marker.sourceSha256 && staleBackupHash !== marker.priorSourceSha256) {
2638
+ this.#warnLegacyFallbackMigration(
2639
+ `Settings: the migration backup ${backup} no longer matches the marker hash; leaving source/backup/marker untouched`,
2640
+ );
2641
+ return;
2642
+ }
2643
+ const parsedBackup = JSON.parse(staleBackupRead.text) as unknown;
2644
+ if (!parsedBackup || typeof parsedBackup !== "object" || Array.isArray(parsedBackup)) {
2645
+ this.#warnLegacyFallbackMigration(
2646
+ `Settings: the migration backup ${backup} has a non-mapping root; leaving source/backup/marker untouched`,
2647
+ );
2648
+ return;
2649
+ }
2650
+ staleBackupDoc = parsedBackup as Record<string, unknown>;
2651
+ } catch {
2652
+ this.#warnLegacyFallbackMigration(
2653
+ `Settings: could not read the migration backup ${backup} for malformed-source recovery; leaving source/backup/marker untouched`,
2654
+ );
2655
+ return;
2656
+ }
2657
+ const staleUnsets: AtomicYamlPatch[] = [];
2658
+ const staleFlatKeys: string[] = [];
2659
+ for (const key of marker.migratedKeys) {
2660
+ const targetValue = extractWorkflowSetting(tx.current, key, { flat: false });
2661
+ if (!targetValue.present) continue;
2662
+ const backupValue = extractWorkflowSetting(staleBackupDoc, key);
2663
+ const targetHash = nodeCrypto
2664
+ .createHash("sha256")
2665
+ .update(JSON.stringify(targetValue.value))
2666
+ .digest("hex");
2667
+ const migrationOwned =
2668
+ (backupValue.present &&
2669
+ this.#coerceWorkflowScalar(key, backupValue.value) === targetValue.value) ||
2670
+ (marker.repairsApplied === true &&
2671
+ marker.repairValueHashes?.[key] !== undefined &&
2672
+ targetHash === marker.repairValueHashes[key]);
2673
+ if (!migrationOwned) continue;
2674
+ staleUnsets.push({ path: key, op: "unset" });
2675
+ if (Object.hasOwn(tx.current as Record<string, unknown>, key)) staleFlatKeys.push(key);
2676
+ }
2677
+ await tx.applyPatchesAndRemoveTopLevelKeys(staleUnsets, staleFlatKeys);
2678
+ }
2679
+ if (await this.#configRootSourceMatches(source, sourceSha256)) {
2680
+ await this.#recordConfigRootMalformedEvidence(tx, source);
2681
+ }
2682
+ this.#warnLegacyFallbackMigration(
2683
+ `Settings: config-root workflow migration found a malformed root in ${source}; leaving source/backup/marker unchanged`,
2684
+ );
2685
+ return;
2686
+ }
2687
+ // A `null`/`~` YAML root is treated by #loadYaml as a malformed
2688
+ // config (settings stay read-only until repaired), so the migration
2689
+ // must treat it like the other non-object roots: abort without
2690
+ // writing or consuming the legacy source.
2691
+ if (tx.root !== undefined && (tx.root === null || typeof tx.root !== "object" || Array.isArray(tx.root))) {
2692
+ this.#warnLegacyFallbackMigration(
2693
+ `Settings: config-root workflow migration target ${target} has a non-object or null YAML root; not migrating`,
2694
+ );
2695
+ return;
2696
+ }
2697
+ const targetDoc = tx.root === undefined ? {} : (tx.root as Record<string, unknown>);
2698
+ const migratedKeys: WorkflowSettingKey[] = [];
2699
+ const patches: AtomicYamlPatch[] = [];
2700
+ const flatKeysToRemove: string[] = [];
2701
+ // A pending marker means a crashed run may have left a STALE patch
2702
+ // in the target; if the source changed since that marker, the stale
2703
+ // target value must not suppress the key (it would shadow the edit
2704
+ // and move it to .bak). Reapply the current source value over it.
2705
+ const stalePendingOverride = marker?.status === "pending" && sourceSha256 !== marker.sourceSha256;
2706
+ // Every unresolved STRICT ralplan key is collected across the loop and
2707
+ // written to ONE evidence file after it, so a shared file records all
2708
+ // of them (the project migration does the same); a per-key write on
2709
+ // the first abort would leave later keys silently defaulting.
2710
+ const invalidStrictKeys: { key: WorkflowSettingKey; value: unknown }[] = [];
2711
+ for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
2712
+ // Only keys the crashed run actually recorded are migration-owned
2713
+ // in the changed-pending window: they may be reapplied, unset, or
2714
+ // overridden by the current source, but a key the marker did NOT
2715
+ // record was skipped because config.yml already held a valid
2716
+ // higher-precedence user value, which must never be clobbered.
2717
+ const staleMarkerKey = stalePendingOverride && marker?.migratedKeys.includes(key);
2718
+ const extracted = extractWorkflowSetting(sourceDoc, key);
2719
+ if (extracted.malformedParent) {
2720
+ if (!key.startsWith("gjc.ralplan.")) {
2721
+ // A malformed parent for a TOLERANT workflow key is ignored
2722
+ // like any other invalid tolerant value (the tolerant
2723
+ // runtime falls back); only strict ralplan keys must fail
2724
+ // loudly, so never write a global malformed marker for it.
2725
+ continue;
2726
+ }
2727
+ // A non-mapping workflow parent in the source (e.g.
2728
+ // `{"gjc":{"ralplan":"broken"}}`) is malformed legacy JSON
2729
+ // that strict ralplan must fail on (exit 2); completing the
2730
+ // migration would deactivate the source and silently use
2731
+ // defaults. Under a changed-pending recovery (crash after
2732
+ // the patch), first clear the stale marker-owned target
2733
+ // patches so the malformed source is visible to strict
2734
+ // ralplan instead of being shadowed by the old agent value.
2735
+ // Clear ONLY targets that match a verifiable migration write:
2736
+ // the backup (the migration's copy) or a committed repair
2737
+ // hash (repairsApplied). A target the user replaced after
2738
+ // the crash is a genuine override and must not be cleared
2739
+ // merely because the source is now malformed.
2740
+ if (marker?.status === "pending") {
2741
+ // Hash-verify the backup against the marker BEFORE trusting its
2742
+ // contents: an edited/corrupted backup is not evidence of what
2743
+ // the migration wrote, and a coincidentally matching override
2744
+ // must never be classified as migration-owned.
2745
+ let staleBackupDoc: Record<string, unknown> | null = null;
2746
+ if (backupExists) {
2747
+ try {
2748
+ const staleRead = await this.#readBackupBytes(backup);
2749
+ const staleHash = nodeCrypto
2750
+ .createHash("sha256")
2751
+ .update(Buffer.from(staleRead.bytes))
2752
+ .digest("hex");
2753
+ if (staleHash === marker.sourceSha256 || staleHash === marker.priorSourceSha256) {
2754
+ staleBackupDoc = JSON.parse(staleRead.text) as Record<string, unknown>;
2755
+ }
2756
+ } catch {
2757
+ // Unreadable or hash-mismatched backup: cannot verify
2758
+ // ownership; leave recovery state untouched.
2759
+ }
2760
+ }
2761
+ const staleUnsets: AtomicYamlPatch[] = [];
2762
+ const staleFlatKeys: string[] = [];
2763
+ for (const ownedKey of marker.migratedKeys) {
2764
+ const ownedValue = extractWorkflowSetting(tx.current, ownedKey, { flat: false });
2765
+ if (!ownedValue.present) continue;
2766
+ const ownedBackupValue = staleBackupDoc
2767
+ ? extractWorkflowSetting(staleBackupDoc, ownedKey)
2768
+ : { present: false, value: undefined };
2769
+ const ownedHash = nodeCrypto
2770
+ .createHash("sha256")
2771
+ .update(JSON.stringify(ownedValue.value))
2772
+ .digest("hex");
2773
+ const verifiable =
2774
+ (ownedBackupValue.present &&
2775
+ this.#coerceWorkflowScalar(ownedKey, ownedBackupValue.value) === ownedValue.value) ||
2776
+ (marker.repairsApplied === true &&
2777
+ marker.repairValueHashes?.[ownedKey] !== undefined &&
2778
+ ownedHash === marker.repairValueHashes[ownedKey]);
2779
+ if (verifiable) {
2780
+ staleUnsets.push({ path: ownedKey, op: "unset" });
2781
+ if (Object.hasOwn(tx.current as Record<string, unknown>, ownedKey)) {
2782
+ staleFlatKeys.push(ownedKey);
2783
+ }
2784
+ }
2785
+ }
2786
+ await tx.applyPatchesAndRemoveTopLevelKeys(staleUnsets, staleFlatKeys);
2787
+ }
2788
+ if (await this.#configRootSourceMatches(source, sourceSha256)) {
2789
+ await this.#recordConfigRootMalformedEvidence(tx, source);
2790
+ }
2791
+ this.#warnLegacyFallbackMigration(
2792
+ `Settings: config-root workflow migration aborted: ${source} has a non-mapping parent for ${key}; leaving source/backup/marker unchanged`,
2793
+ );
2794
+ return;
2795
+ }
2796
+ if (!extracted.present) {
2797
+ // A key the user REMOVED from the source (after a crash that
2798
+ // had already patched config.yml) should drop its stale
2799
+ // target value, so the deletion is honored. But ownership is
2800
+ // verifiable only against the migration's backup copy: in
2801
+ // the pending no-backup recovery the target value may be a
2802
+ // NEWER `gjc config set` override, so never unset it
2803
+ // blindly - leave it and warn.
2804
+ if (staleMarkerKey && extractWorkflowSetting(targetDoc, key, { flat: false }).present) {
2805
+ if (backupExists) {
2806
+ // Only unset a target that STILL matches the migration's
2807
+ // write (the hash-verified backup copy or committed repair
2808
+ // evidence): a target the user edited after the crash is a
2809
+ // genuine override and must not be removed merely because
2810
+ // the backup exists.
2811
+ const removedKeyTarget = extractWorkflowSetting(targetDoc, key, { flat: false });
2812
+ let removedKeyBackupDoc: Record<string, unknown> | null = null;
2813
+ try {
2814
+ const removedKeyRead = await this.#readBackupBytes(backup);
2815
+ const removedKeyHash = nodeCrypto
2816
+ .createHash("sha256")
2817
+ .update(Buffer.from(removedKeyRead.bytes))
2818
+ .digest("hex");
2819
+ if (
2820
+ removedKeyHash === marker?.sourceSha256 ||
2821
+ removedKeyHash === marker?.priorSourceSha256
2822
+ ) {
2823
+ removedKeyBackupDoc = JSON.parse(removedKeyRead.text) as Record<string, unknown>;
2824
+ }
2825
+ } catch {
2826
+ // Unreadable or hash-mismatched backup: cannot verify
2827
+ // ownership; keep the target value.
2828
+ }
2829
+ const removedKeyBackupValue = removedKeyBackupDoc
2830
+ ? extractWorkflowSetting(removedKeyBackupDoc, key)
2831
+ : { present: false, value: undefined };
2832
+ const removedKeyTargetHash = nodeCrypto
2833
+ .createHash("sha256")
2834
+ .update(JSON.stringify(removedKeyTarget.value))
2835
+ .digest("hex");
2836
+ const removedKeyVerifiable =
2837
+ (removedKeyBackupValue.present &&
2838
+ this.#coerceWorkflowScalar(key, removedKeyBackupValue.value) ===
2839
+ removedKeyTarget.value) ||
2840
+ (marker?.repairsApplied === true &&
2841
+ marker?.repairValueHashes?.[key] !== undefined &&
2842
+ removedKeyTargetHash === marker?.repairValueHashes[key]);
2843
+ if (removedKeyVerifiable) {
2844
+ patches.push({ path: key, op: "unset" });
2845
+ if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
2846
+ }
2847
+ } else {
2848
+ // Ownership is unverifiable: abort the recovery (the
2849
+ // source stays active) instead of completing with the
2850
+ // key omitted from migratedKeys, which would
2851
+ // deactivate the edited source and leave the stale
2852
+ // target effective permanently.
2853
+ this.#warnLegacyFallbackMigration(
2854
+ `Settings: config-root workflow migration aborted: ${key} removed from ${source} but its target value cannot be verified as the migration write (no backup); keeping the source active`,
2855
+ );
2856
+ return;
2857
+ }
2858
+ }
2859
+ continue;
2860
+ }
2861
+ // A *valid* present target value for this key wins: the legacy
2862
+ // config-root value (valid or not) is never observed by the
2863
+ // resolver, so skip this key entirely instead of aborting the
2864
+ // whole migration over a stale overridden value (unless the
2865
+ // target itself holds a stale patch for a marker-recorded key -
2866
+ // see above).
2867
+ const targetValue = extractWorkflowSetting(targetDoc, key, { flat: false });
2868
+ if (targetValue.malformedParent) {
2869
+ // A non-object intermediate in config.yml (e.g.
2870
+ // `gjc: { ralplan: "repair-me" }`) is malformed user data
2871
+ // that #loadYaml would report for repair; writing the
2872
+ // migrated value would silently replace it. Abort and leave
2873
+ // everything untouched.
2874
+ this.#warnLegacyFallbackMigration(
2875
+ `Settings: config-root workflow migration aborted: ${target} has a non-mapping parent for ${key}; leaving source/backup/marker untouched`,
2876
+ );
2877
+ return;
2878
+ }
2879
+ if (targetValue.present && this.#workflowKeyValueIsValid(key, targetValue.value)) {
2880
+ // A *valid* present target value wins unless the key is a
2881
+ // stale marker-owned key under a changed-pending recovery
2882
+ // (staleMarkerKey), where the current source value must be
2883
+ // reapplied over the stale patch below.
2884
+ if (!staleMarkerKey) {
2885
+ // Retry (unchanged source) or a genuine user value:
2886
+ // still schedule the flat-form cleanup for marker-owned
2887
+ // keys so a dotted top-level key left by a crash between
2888
+ // applyPatches and removeTopLevelKeys does not keep
2889
+ // config.yml rejected by the generated schema - and keep
2890
+ // the key in the rebuilt migratedKeys so ownership
2891
+ // survives the marker rewrite.
2892
+ if (marker?.migratedKeys.includes(key)) {
2893
+ // Retain ownership scope across the pre-backup crash window
2894
+ // (the target patch committed but the backup move did not):
2895
+ // the retry's own move creates the durable backup, and every
2896
+ // later unset verifies the target value against that backup,
2897
+ // so an editor's DIFFERENT value is never reclaimed.
2898
+ if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
2899
+ if (!migratedKeys.includes(key)) migratedKeys.push(key);
2900
+ }
2901
+ continue;
2902
+ }
2903
+ }
2904
+ // Validate the legacy value BEFORE migrating it. An invalid
2905
+ // tolerant value (e.g. `"gjc.ultragoal.nudgeBudget": "bad"`)
2906
+ // must not be copied into the durable config.yml, where
2907
+ // Settings.load()/config doctor would report it on every
2908
+ // startup (previously the tolerant runtime simply ignored it
2909
+ // in settings.json and fell back to the default).
2910
+ if (!this.#workflowKeyValueIsValid(key, extracted.value)) {
2911
+ if (
2912
+ staleMarkerKey &&
2913
+ backupExists &&
2914
+ extractWorkflowSetting(targetDoc, key, { flat: false }).present
2915
+ ) {
2916
+ // Changed-pending recovery: unset the stale crashed patch
2917
+ // for a marker-recorded key so the current source value
2918
+ // (valid or invalid, tolerant or strict) is honored -
2919
+ // never leave a stale target value shadowing it.
2920
+ patches.push({ path: key, op: "unset" });
2921
+ if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
2922
+ }
2923
+ // Strict ralplan keys must keep the legacy source active only
2924
+ // when the invalid value would actually be the winning layer:
2925
+ // consuming it would silently fall back to defaults instead of
2926
+ // failing loudly (the strict resolver throws exit 2 on the
2927
+ // invalid value). Tolerant keys are simply skipped.
2928
+ if (key.startsWith("gjc.ralplan.")) {
2929
+ // If the unset above was queued, apply it so the invalid
2930
+ // legacy source is visible (exit 2) instead of being
2931
+ // shadowed by the stale valid target value.
2932
+ if (
2933
+ staleMarkerKey &&
2934
+ backupExists &&
2935
+ extractWorkflowSetting(targetDoc, key, { flat: false }).present
2936
+ ) {
2937
+ patches.push({ path: key, op: "unset" });
2938
+ if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
2939
+ }
2940
+ // Apply ALL queued repairs (earlier keys' unsets and this
2941
+ // key's unset) before aborting, so no stale target value
2942
+ // for any marker-recorded key survives in config.yml.
2943
+ // Only under a changed-pending recovery: in a FRESH
2944
+ // migration the queued patches are plain SETs for valid
2945
+ // keys, and applying them on an abort would write
2946
+ // un-marker'd partial artifacts that the target-wins
2947
+ // rule would freeze.
2948
+ if (stalePendingOverride) {
2949
+ // Apply only MARKER-OWNED repairs: fresh SETs for
2950
+ // unrecorded keys must not be committed on an abort
2951
+ // (the marker does not own them; committing would
2952
+ // shadow later source edits forever via the
2953
+ // valid-target guard).
2954
+ const repairPatches = patches.filter(patch =>
2955
+ marker?.migratedKeys.includes(patch.path as WorkflowSettingKey),
2956
+ );
2957
+ const repairFlatKeys = flatKeysToRemove.filter(key =>
2958
+ marker?.migratedKeys.includes(key as WorkflowSettingKey),
2959
+ );
2960
+ // One atomic write for the repairs + flat cleanup.
2961
+ if (repairPatches.length > 0 || repairFlatKeys.length > 0) {
2962
+ await tx.applyPatchesAndRemoveTopLevelKeys(repairPatches, repairFlatKeys);
2963
+ }
2964
+ }
2965
+ invalidStrictKeys.push({ key, value: extracted.value });
2966
+ this.#warnLegacyFallbackMigration(
2967
+ `Settings: config-root workflow migration aborted: invalid strict ralplan value for ${key} in ${source}; keeping the legacy source active so gjc ralplan still fails loudly`,
2968
+ );
2969
+ continue;
2970
+ }
2971
+ continue;
2972
+ }
2973
+ // The changed-pending REAPPLY overwrites a present target value;
2974
+ // without a backup the migration write is unverifiable and the
2975
+ // target may be an editor's newer value. Abort the recovery
2976
+ // (keeping the source active) instead of completing with the
2977
+ // key omitted from migratedKeys, which would deactivate the
2978
+ // edited source and leave the stale target effective forever.
2979
+ if (stalePendingOverride && marker?.migratedKeys.includes(key) && targetValue.present && !backupExists) {
2980
+ this.#warnLegacyFallbackMigration(
2981
+ `Settings: config-root workflow migration aborted: ${key} reapplied by the source but its target value cannot be verified as the migration write (no backup); keeping the source active`,
2982
+ );
2983
+ return;
2984
+ }
2985
+ migratedKeys.push(key);
2986
+ // Persist the COERCED value (quoted numeric string -> number), not
2987
+ // the raw string: a schema-backed config.yml must hold values the
2988
+ // generated JSON schema accepts and Settings does not need to
2989
+ // re-coerce on every load.
2990
+ patches.push({ path: key, op: "set", value: this.#coerceWorkflowScalar(key, extracted.value) });
2991
+ // Flat keys are checked before nested ones by
2992
+ // extractWorkflowSetting, so an invalid flat key (e.g.
2993
+ // `"gjc.ralplan.maxIterations": bad`) would keep masking the
2994
+ // migrated nested value after the legacy source is moved to .bak.
2995
+ // Remove the flat form verbatim (the patch grammar cannot address
2996
+ // dotted top-level key names).
2997
+ if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
2998
+ }
2999
+
3000
+ // ALL unresolved strict keys were collected above; write ONE evidence
3001
+ // file recording every one of them, then abort so the legacy source
3002
+ // stays active and strict ralplan keeps failing loudly for each key.
3003
+ if (invalidStrictKeys.length > 0) {
3004
+ // Revalidate the source before retaining evidence: an editor may
3005
+ // repair or replace settings.json while the migration runs, and
3006
+ // stale evidence would make the direct command exit 2 for a
3007
+ // now-valid source.
3008
+ if (!(await this.#configRootSourceMatches(source, sourceSha256))) {
3009
+ this.#warnLegacyFallbackMigration(
3010
+ `Settings: config-root workflow migration aborted without evidence: ${source} changed while retaining strict errors`,
3011
+ );
3012
+ return;
3013
+ }
3014
+ const evidenceOk = await this.#writeStrictInvalidEvidence(
3015
+ `${source}.strict-invalid`,
3016
+ source,
3017
+ invalidStrictKeys,
3018
+ );
3019
+ if (!evidenceOk) {
3020
+ // Read-only fallback: persist the invalid values into the target
3021
+ // config.yml (the only surface the strict resolver reads) so the
3022
+ // exit-2 error stays observable when the evidence cannot be
3023
+ // written. Only ABSENT target keys get fallback values: a
3024
+ // present-but-invalid user-authored value already keeps exit-2
3025
+ // observable on its own, and overwriting it would destroy the
3026
+ // user's configuration when the fallback is later cleaned up or
3027
+ // rolled back.
3028
+ const fallbackEntries = invalidStrictKeys.filter(retained => {
3029
+ const targetValue = extractWorkflowSetting(tx.root, retained.key, { flat: false });
3030
+ return !targetValue.malformedParent && !targetValue.present;
3031
+ });
3032
+ if (fallbackEntries.length > 0) {
3033
+ // Publish the ownership marker BEFORE the values: a crash between
3034
+ // the two leaves the marker (and no values), never values without
3035
+ // a marker the next load could not identify or clean up. Without
3036
+ // the marker, skip the values - an untracked fallback would exit
3037
+ // 2 forever with no way to clear it.
3038
+ const fallbackMarkerOk = await this.#writeFallbackInvalidKeys(
3039
+ source,
3040
+ await this.#mergeFallbackInvalidKeys(
3041
+ source,
3042
+ fallbackEntries,
3043
+ this.#configRootFallbackInvalidMarkerPath(source),
3044
+ ),
3045
+ this.#configRootFallbackInvalidMarkerPath(source),
3046
+ );
3047
+ if (fallbackMarkerOk) {
3048
+ this.#warnLegacyFallbackMigration(
3049
+ `Settings: config-root workflow migration persisted strict-invalid values into ${target} as fallback evidence`,
3050
+ );
3051
+ await tx.applyPatches(
3052
+ fallbackEntries.map(retained => ({
3053
+ path: retained.key,
3054
+ op: "set" as const,
3055
+ value: retained.value,
3056
+ })),
3057
+ );
3058
+ } else {
3059
+ this.#warnLegacyFallbackMigration(
3060
+ `Settings: config-root workflow migration could not track strict-invalid fallback values; skipping the fallback`,
3061
+ );
3062
+ }
3063
+ }
3064
+ }
3065
+ return;
3066
+ }
3067
+
3068
+ // An invalid/untrusted marker must never suppress migration. Preserve
3069
+ // its bytes by a no-clobber quarantine; abort if quarantine is
3070
+ // impossible. (A malformed marker parses to null, so the file's
3071
+ // existence is the signal, not a non-null marker object.)
3072
+ if (markerFileExists && marker === null) {
3073
+ const corruptPath = `${markerPath}.corrupt`;
3074
+ if (!(await this.#moveLegacySourceNoReplace(markerPath, corruptPath))) {
3075
+ this.#warnLegacyFallbackMigration(
3076
+ `Settings: config-root workflow migration could not quarantine invalid marker ${markerPath}; leaving unchanged`,
3077
+ );
3078
+ return;
3079
+ }
3080
+ }
3081
+
3082
+ const startedAt =
3083
+ marker?.status === "pending" && typeof marker.startedAt === "string"
3084
+ ? marker.startedAt
3085
+ : new Date().toISOString();
3086
+ // The pending marker is the ownership record if this run crashes
3087
+ // after the target patch; bind it to the directory that will
3088
+ // receive the write so a later replacement profile can never
3089
+ // inherit its ownership claims.
3090
+ const pendingTargetIdentity = await this.#workflowMigrationTargetIdentity(target);
3091
+ if (pendingTargetIdentity === null) {
3092
+ this.#warnLegacyFallbackMigration(
3093
+ `Settings: config-root workflow migration cannot verify target directory ${path.dirname(target)} before the pending write; leaving source and marker untouched`,
3094
+ );
3095
+ return;
3096
+ }
3097
+ await this.#writeWorkflowMigrationMarkerAtomic(markerPath, {
3098
+ version: WORKFLOW_MIGRATION_MARKER_VERSION,
3099
+ status: "pending",
3100
+ sourcePath: source,
3101
+ backupPath: backup,
3102
+ targetPath: target,
3103
+ ...pendingTargetIdentity,
3104
+ sourceSha256,
3105
+ migratedKeys,
3106
+ startedAt,
3107
+ });
3108
+ pendingMarkerWritten = true;
3109
+
3110
+ // The legacy source may have been edited since `sourceSha256` was
3111
+ // computed and the patches built. Re-hash BEFORE writing anything so
3112
+ // a stale patch never lands in the higher-precedence config.yml,
3113
+ // and snapshot the target so a late mismatch can revert it.
3114
+ if ((await this.#sha256File(source)) !== sourceSha256) {
3115
+ // Nothing was patched by THIS run: the pending marker's
3116
+ // migratedKeys would falsely claim ownership of these patches
3117
+ // on the next changed-pending recovery (staleMarkerKey),
3118
+ // letting it overwrite a valid user target override. Remove it
3119
+ // so the next load starts fresh - but ONLY when no marker
3120
+ // existed before this run: a PRIOR run that already patched
3121
+ // config.yml left that marker as the only evidence its target
3122
+ // values are migration-written, so it must be retained.
3123
+ if (!marker) {
3124
+ await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
3125
+ }
3126
+ this.#warnLegacyFallbackMigration(
3127
+ `Settings: config-root workflow migration aborted: ${source} changed during migration; marker ${marker ? "retained" : "cleared"}, source left active for the next load`,
3128
+ );
3129
+ return;
3130
+ }
3131
+ const targetIdentityBeforePatch = await this.#workflowMigrationTargetIdentity(target);
3132
+ if (targetIdentityBeforePatch === null) {
3133
+ this.#warnLegacyFallbackMigration(
3134
+ `Settings: config-root workflow migration cannot verify target directory ${path.dirname(target)} before patch; leaving source and marker pending`,
3135
+ );
3136
+ return;
3137
+ }
3138
+ const prePatchTargetSnapshot = structuredClone(tx.current);
3139
+ // Apply the nested patches AND the flat-form cleanup in a single
3140
+ // atomic write: two separate writes would let an external editor's
3141
+ // config.yml change (which does not participate in the file lock)
3142
+ // land between them and be overwritten.
3143
+ await tx.applyPatchesAndRemoveTopLevelKeys(patches, flatKeysToRemove);
3144
+ targetPatchCommitted = true;
3145
+ // Capture the identity of the config.yml FILE this write produced.
3146
+ // An external editor atomically replacing the file after this point
3147
+ // yields a NEW inode; completion must reject that replacement instead
3148
+ // of publishing ownership for a value the migration never wrote.
3149
+ const targetFileIdentityAfterPatch = await this.#targetFileIdentity(target);
3150
+ if (targetFileIdentityAfterPatch === null) {
3151
+ this.#warnLegacyFallbackMigration(
3152
+ `Settings: config-root workflow migration could not verify the patched target file ${target}; leaving source, backup, and marker pending`,
3153
+ );
3154
+ return;
3155
+ }
3156
+
3157
+ // Re-hash immediately before the no-replace move; on mismatch, revert
3158
+ // the target to its pre-patch state so the next load re-runs against
3159
+ // the current file instead of resolving a stale agent-config value.
3160
+ let preMoveSourceHash: string | null = null;
3161
+ try {
3162
+ preMoveSourceHash = await this.#sha256File(source);
3163
+ } catch {
3164
+ // The source was deleted after the patch: revert the target and
3165
+ // clear the now-obsolete marker so the deletion is honored (the
3166
+ // later post-copy deletion path does the same).
3167
+ await tx.replaceCurrent(prePatchTargetSnapshot);
3168
+ if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
3169
+ await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
3170
+ this.#warnLegacyFallbackMigration(
3171
+ `Settings: config-root workflow migration aborted: ${source} was deleted during migration; target reverted, ${backupCreatedByThisRun ? "backup removed" : "external backup preserved"}, marker cleared`,
3172
+ );
3173
+ return;
3174
+ }
3175
+ if (preMoveSourceHash !== sourceSha256) {
3176
+ await tx.replaceCurrent(prePatchTargetSnapshot);
3177
+ if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
3178
+ await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
3179
+ this.#warnLegacyFallbackMigration(
3180
+ `Settings: config-root workflow migration aborted: ${source} changed during migration; target reverted, ${backupCreatedByThisRun ? "backup removed" : "external backup preserved"}, marker cleared`,
3181
+ );
3182
+ return;
3183
+ }
3184
+ if (!(await this.#moveLegacySourceNoReplace(source, backup, sourceSha256))) {
3185
+ // The target was already patched; revert it so the higher
3186
+ // precedence config.yml does not shadow the still-active source
3187
+ // (a `.bak` that appeared in the window would otherwise leave
3188
+ // the pending yes/yes recovery row warning on mismatch forever).
3189
+ await tx.replaceCurrent(prePatchTargetSnapshot);
3190
+ this.#warnLegacyFallbackMigration(
3191
+ `Settings: config-root workflow migration could not move ${source} to ${backup} without overwrite; target reverted, pending marker retained for retry`,
3192
+ );
3193
+ return;
3194
+ }
3195
+ backupCreatedByThisRun = true;
3196
+ // Capture the backup's inode for the guarded removals below. The
3197
+ // copy is byte-identical to the verified source at this point
3198
+ // (the move re-hashed the source before completing), so the
3199
+ // expected sha is sourceSha256 and the inode identifies the file
3200
+ // this run created; a backup replaced or edited afterwards fails
3201
+ // the guard and is preserved.
3202
+ try {
3203
+ backupIno = (await fs.promises.stat(backup)).ino;
3204
+ } catch {
3205
+ // The backup vanished right after the move; nothing of ours
3206
+ // to remove on the abort paths.
3207
+ backupIno = null;
3208
+ }
3209
+
3210
+ // The source may have been edited in the narrow window after the
3211
+ // pre-move check but before/during the move; verify the bytes we
3212
+ // actually moved. The source is kept ACTIVE on every path, so on
3213
+ // mismatch the edit is already live: revert the target and remove
3214
+ // the now-superseded backup so the next load sees pending + source
3215
+ // + no backup and re-runs the fresh transaction against the edited
3216
+ // file (never completing behind a stale hash).
3217
+ if ((await this.#sha256File(backup)) !== sourceSha256) {
3218
+ await tx.replaceCurrent(prePatchTargetSnapshot);
3219
+ if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
3220
+ this.#warnLegacyFallbackMigration(
3221
+ `Settings: config-root workflow migration aborted: ${source} changed during migration; target reverted, backup removed, source left active for the next load`,
3222
+ );
3223
+ return;
3224
+ }
3225
+ // On the copy fallback (filesystems without hard links) the source
3226
+ // is deliberately kept ACTIVE, so a same-key edit after the copy is
3227
+ // shadowed by the higher-precedence patched config.yml; verify the
3228
+ // source (absent = externally deleted) and revert on
3229
+ // mismatch, removing the now-superseded backup so the next load
3230
+ // sees pending + source + no backup and re-runs the fresh
3231
+ // transaction against the edited file.
3232
+ let sourceHashAfterMove: string | null = null;
3233
+ try {
3234
+ sourceHashAfterMove = await this.#sha256File(source);
3235
+ } catch (error) {
3236
+ // The source is kept ACTIVE on every path, so ENOENT can only be
3237
+ // a concurrent DELETION of the legacy file: honor it by undoing
3238
+ // the patch and backup and clearing the pending marker (there is
3239
+ // nothing left to migrate), instead of completing behind the old
3240
+ // values and silently undoing the deletion.
3241
+ if (isEnoent(error)) {
3242
+ await tx.replaceCurrent(prePatchTargetSnapshot);
3243
+ if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
3244
+ await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
3245
+ this.#warnLegacyFallbackMigration(
3246
+ `Settings: config-root workflow migration aborted: ${source} was deleted during migration; target reverted, backup removed, marker cleared`,
3247
+ );
3248
+ return;
3249
+ }
3250
+ // Non-ENOENT read failure: fail closed (revert + retain
3251
+ // pending) rather than completing behind a possibly-edited
3252
+ // source.
3253
+ await tx.replaceCurrent(prePatchTargetSnapshot);
3254
+ this.#warnLegacyFallbackMigration(
3255
+ `Settings: config-root workflow migration aborted: could not re-read ${source} after the move; target reverted, pending marker retained`,
3256
+ );
3257
+ return;
3258
+ }
3259
+ if (sourceHashAfterMove !== null && sourceHashAfterMove !== sourceSha256) {
3260
+ await tx.replaceCurrent(prePatchTargetSnapshot);
3261
+ if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
3262
+ this.#warnLegacyFallbackMigration(
3263
+ `Settings: config-root workflow migration aborted: ${source} edited after the copy; target reverted, backup removed, source left active for the next load`,
3264
+ );
3265
+ return;
3266
+ }
3267
+
3268
+ const targetIdentity = await this.#workflowMigrationTargetIdentity(target, targetIdentityBeforePatch);
3269
+ if (targetIdentity === null) {
3270
+ this.#warnLegacyFallbackMigration(
3271
+ `Settings: config-root workflow migration target directory changed after patch; leaving source, backup, and marker pending`,
3272
+ );
3273
+ return;
3274
+ }
3275
+ if ((await this.#targetFileIdentity(target)) !== targetFileIdentityAfterPatch) {
3276
+ this.#warnLegacyFallbackMigration(
3277
+ `Settings: config-root workflow migration target file ${target} was replaced after the patch; leaving source, backup, and marker pending`,
3278
+ );
3279
+ return;
3280
+ }
3281
+ await this.#writeWorkflowMigrationMarkerAtomic(markerPath, {
3282
+ version: WORKFLOW_MIGRATION_MARKER_VERSION,
3283
+ status: "complete",
3284
+ sourcePath: source,
3285
+ backupPath: backup,
3286
+ targetPath: target,
3287
+ ...targetIdentity,
3288
+ targetFileIdentity: targetFileIdentityAfterPatch,
3289
+ sourceSha256,
3290
+ migratedKeys,
3291
+ startedAt,
3292
+ completedAt: new Date().toISOString(),
3293
+ });
3294
+ // The legacy source is RETIRED once the migration completes: the
3295
+ // resolver no longer reads settings.json (config.yml is the only
3296
+ // settings surface), so a surviving copy - the copy-fallback keeps
3297
+ // the source active - is removed here. The migration's own .bak
3298
+ // remains as the user-data backup. Strict-invalid evidence is cleared
3299
+ // too: the retained invalid state no longer exists, so strict
3300
+ // resolution must stop throwing.
3301
+ await this.#retireConfigRootSource(source, sourceSha256);
3302
+ await fs.promises.rm(`${source}.strict-invalid`, { force: true }).catch(() => undefined);
3303
+ logger.debug("Settings: migrated config-root workflow settings to config.yml", {
3304
+ source,
3305
+ target,
3306
+ migratedKeys,
3307
+ });
3308
+ });
3309
+ } catch (error) {
3310
+ // A CAS rejection means an external editor changed config.yml before
3311
+ // any patch of this run applied: a pending marker's migratedKeys
3312
+ // would falsely claim ownership of never-applied patches, so clear it
3313
+ // A CAS rejection means an external editor changed config.yml before a
3314
+ // write of THIS run applied. The pending marker is RETAINED: in a
3315
+ // changed-pending recovery the prior run already patched config.yml
3316
+ // and the marker is the only evidence that the existing target value
3317
+ // is migration-written - clearing it would let the stale value pass
3318
+ // the valid-target guard and complete with the key omitted. (A
3319
+ // retained marker whose claims were never applied is handled safely
3320
+ // by the unverifiable-ownership abort, which keeps the source active.)
3321
+ if (error instanceof AtomicYamlConflictError) {
3322
+ // A fresh migration wrote its pending marker but an external editor
3323
+ // changed config.yml before the target patch: nothing was applied,
3324
+ // so the marker's migratedKeys must not claim ownership of
3325
+ // never-applied writes (a retry would otherwise record the editor's
3326
+ // matching value as migration-owned). Clear it; a PRIOR run's marker
3327
+ // (preMarkerExists) or a committed target write stays as ownership
3328
+ // evidence for the changed-pending recovery.
3329
+ if (pendingMarkerWritten && !targetPatchCommitted && !preMarkerExists) {
3330
+ await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
3331
+ this.#warnLegacyFallbackMigration(
3332
+ `Settings: config-root workflow migration aborted: ${target} changed externally before the target write; unapplied pending marker cleared`,
3333
+ );
3334
+ } else {
3335
+ this.#warnLegacyFallbackMigration(
3336
+ `Settings: config-root workflow migration aborted: ${target} changed externally during migration; pending marker retained`,
3337
+ );
3338
+ }
3339
+ return;
3340
+ }
3341
+ // A malformed target config.yml must not abort settings load: warn and
3342
+ // leave source/backup/marker untouched so #loadYaml's recoverable
3343
+ // malformed-config diagnostics still run after the migration returns.
3344
+ this.#warnLegacyFallbackMigration(
3345
+ `Settings: config-root workflow migration could not run against ${target}: ${error instanceof Error ? error.message : String(error)}; leaving source/backup/marker untouched`,
3346
+ );
3347
+ }
3348
+ }
3349
+
3350
+ /**
3351
+ * One-time copy of the PROJECT `.gjc/settings.json` workflow keys into the
3352
+ * project `.gjc/config.yml` (absent-only). The workflow resolver reads
3353
+ * config.yml only, so a project that still stores its ralplan/
3354
+ * deep-interview/ultragoal overrides in the legacy file would silently fall
3355
+ * back to defaults after the config.yml-only switch without this copy.
3356
+ *
3357
+ * Unlike the config-root migration the source file is NOT retired here: the
3358
+ * project settings.json remains a live discovery surface for its non-workflow
3359
+ * settings, so the copy is idempotent (absent-only patches) rather than
3360
+ * consume-once. A later edit of a workflow key in the legacy file is ignored
3361
+ * once config.yml holds the migrated value, matching the "migrate once, then
3362
+ * config.yml is the only workflow surface" contract.
3363
+ *
3364
+ * Runs inside Settings load so both the session path and the direct workflow
3365
+ * commands (which trigger Settings via {@link ensureWorkflowSettingsMigrated})
3366
+ * migrate before any resolver reads config.yml.
3367
+ */
3368
+ async #migrateProjectWorkflowSettings(): Promise<void> {
3369
+ const projectDir = path.resolve(this.#cwd, ".gjc");
3370
+ const source = path.resolve(projectDir, "settings.json");
3371
+ const target = path.resolve(projectDir, "config.yml");
3372
+
3373
+ // Strict-invalid retention evidence is valid only while the source still
3374
+ // holds the exact invalid value that produced it; a repair or a removal
3375
+ // must clear it so strict resolution stops throwing. Runs before the
3376
+ // source-existence short-circuit so a deleted source also clears it.
3377
+ const evidencePath = this.#projectStrictInvalidEvidencePath(source);
3378
+ const retainedEvidence = await this.#readStrictInvalidEvidence(evidencePath);
3379
+ if (retainedEvidence) {
3380
+ try {
3381
+ // Prune to the still-invalid keys: repairing ONE recorded key must
3382
+ // not drop the evidence for the remaining unresolved keys. Runs
3383
+ // under the target lock so a concurrent process's evidence
3384
+ // publication serializes with this prune.
3385
+ await withAtomicYamlConfigTransaction(target, async () => {
3386
+ await this.#pruneStrictInvalidEvidence(source, evidencePath, retainedEvidence);
3387
+ });
3388
+ } catch {
3389
+ // Transient source read failure: retain the evidence sidecar.
3390
+ }
3391
+ }
3392
+
3393
+ // Fallback-invalid cleanup: config.yml values persisted as fallback
3394
+ // evidence are removed when the source no longer holds the invalid value
3395
+ // (key deleted or settings.json removed), so exit 2 does not persist.
3396
+ await this.#clearStaleFallbackInvalidKeys(source, target);
3397
+
3398
+ if (!(await this.#pathExists(source))) return;
3399
+
3400
+ let raw: string;
3401
+ try {
3402
+ raw = await Bun.file(source).text();
3403
+ } catch (error) {
3404
+ if (isEnoent(error)) return;
3405
+ // An EXISTING but unreadable source (EACCES, EIO) must keep the strict
3406
+ // error observable: record malformed evidence so the direct command
3407
+ // fails loudly instead of silently using config.yml/defaults.
3408
+ await this.#recordProjectMalformedEvidence(source, target);
3409
+ this.#warnLegacyFallbackMigration(
3410
+ `Settings: project workflow migration could not read ${source}; recorded malformed evidence`,
3411
+ );
3412
+ return;
3413
+ }
3414
+ let document: unknown;
3415
+ try {
3416
+ document = JSON.parse(raw);
3417
+ } catch {
3418
+ await this.#recordProjectMalformedEvidence(source, target);
3419
+ this.#warnLegacyFallbackMigration(
3420
+ `Settings: project workflow migration found malformed JSON in ${source}; leaving untouched`,
3421
+ );
3422
+ return;
3423
+ }
3424
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
3425
+ await this.#recordProjectMalformedEvidence(source, target);
3426
+ this.#warnLegacyFallbackMigration(
3427
+ `Settings: project workflow migration found a malformed root in ${source}; leaving untouched`,
3428
+ );
3429
+ return;
3430
+ }
3431
+
3432
+ const migratedKeys = await this.#readProjectMigratedKeys(source);
3433
+ const patches: AtomicYamlPatch[] = [];
3434
+ const invalidStrictKeys: { key: WorkflowSettingKey; value: unknown }[] = [];
3435
+ for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
3436
+ // A key already recorded as migrated is owned by config.yml: never
3437
+ // re-import it, even when the target key is absent (e.g. after
3438
+ // `gjc config unset`), so removing a migrated override sticks instead
3439
+ // of the stale legacy value being copied back on the next load.
3440
+ if (migratedKeys.has(key)) continue;
3441
+ const extracted = extractWorkflowSetting(document as Record<string, unknown>, key);
3442
+ if (extracted.malformedParent) {
3443
+ if (!key.startsWith("gjc.ralplan.")) continue;
3444
+ // A non-mapping workflow parent (e.g. `gjc.ralplan: "broken"`) is
3445
+ // malformed legacy JSON that strict ralplan must fail on (exit 2);
3446
+ // record malformed evidence so the strict resolver surfaces it
3447
+ // instead of silently defaulting, and abort the migration. A
3448
+ // malformed parent for a TOLERANT key is skipped like any other
3449
+ // invalid tolerant value - never a global strict marker.
3450
+ await this.#recordProjectMalformedEvidence(source, target);
3451
+ this.#warnLegacyFallbackMigration(
3452
+ `Settings: project workflow migration retained a malformed parent in ${source} for ${key}`,
3453
+ );
3454
+ return;
3455
+ }
3456
+ if (!extracted.present) continue;
3457
+ const value = this.#coerceWorkflowScalar(key, extracted.value);
3458
+ if (!this.#workflowKeyValueIsValid(key, value)) {
3459
+ if (key.startsWith("gjc.ralplan.")) {
3460
+ // Strict ralplan keys must fail loudly while the retained legacy
3461
+ // value is invalid (the resolver reads config.yml only): record
3462
+ // project-layer evidence instead of silently skipping, mirroring
3463
+ // the config-root strict path.
3464
+ invalidStrictKeys.push({ key, value: extracted.value });
3465
+ this.#warnLegacyFallbackMigration(
3466
+ `Settings: project workflow migration retained invalid strict value for ${key} in ${source}`,
3467
+ );
3468
+ continue;
3469
+ }
3470
+ this.#warnLegacyFallbackMigration(
3471
+ `Settings: project workflow migration skipped invalid value for ${key} in ${source}`,
3472
+ );
3473
+ continue;
3474
+ }
3475
+ patches.push({ path: key, op: "set", value });
3476
+ }
3477
+ if (patches.length === 0 && invalidStrictKeys.length === 0) return;
3478
+
3479
+ // Keys this run records as migrated: present in the target (either
3480
+ // already there from an earlier run whose marker was lost, or copied by
3481
+ // this run's commit). Written to the marker only after a successful
3482
+ // publication - never on an abort or a rollback.
3483
+ let newlyOwned: WorkflowSettingKey[] = [];
3484
+ let rollbackRequired = false;
3485
+ let absent: AtomicYamlPatch[] = [];
3486
+ // Pre-publication target states, captured inside the transaction right
3487
+ // before the write and reused by the post-transaction recovery rollback.
3488
+ let beforeStates: Map<string, ProjectTargetBeforeState> = new Map();
3489
+ try {
3490
+ await withAtomicYamlConfigTransaction(target, async tx => {
3491
+ // A target that did not exist when this transaction read it was
3492
+ // CREATED by this run's publication. If the run must roll back, the
3493
+ // rollback leaves an empty config.yml behind, and the resolver treats
3494
+ // any existing non-future target as authoritative - which would
3495
+ // disable the retained settings.json fallback while the marker stays
3496
+ // unwritable. Track it so the rollback can remove the created file.
3497
+ const targetWasAbsent = tx.root === undefined;
3498
+ // A config.yml written by a NEWER schema version is intentionally
3499
+ // read-only across Settings; never patch it (mirrors the config-root
3500
+ // migration guard) so a future schema cannot inherit stale keys.
3501
+ const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
3502
+ if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) {
3503
+ this.#warnLegacyFallbackMigration(
3504
+ `Settings: project workflow migration skipped: ${target} is a future config schema (configSchemaVersion ${targetSchemaVersion} > ${CONFIG_SCHEMA_VERSION})`,
3505
+ );
3506
+ // A future-schema target is read-only, but the retained legacy
3507
+ // source's strict errors must stay observable: the resolver reads
3508
+ // config.yml only, so record strict-invalid evidence when the
3509
+ // source holds invalid ralplan values or is malformed.
3510
+ await this.#retainStrictErrorsFromSource(source, this.#projectStrictInvalidEvidencePath(source));
3511
+ return;
3512
+ }
3513
+ // A project config.yml whose root is a scalar/array/null is malformed
3514
+ // user data: patching would replace the whole document with the
3515
+ // migrated mapping. Abort, mirroring the config-root migration's
3516
+ // root-shape guard, instead of destroying the user's configuration.
3517
+ if (tx.root !== undefined && (tx.root === null || typeof tx.root !== "object" || Array.isArray(tx.root))) {
3518
+ this.#warnLegacyFallbackMigration(
3519
+ `Settings: project workflow migration target ${target} has a non-object or null YAML root; not migrating`,
3520
+ );
3521
+ return;
3522
+ }
3523
+ // A non-mapping parent in the project config.yml (e.g.
3524
+ // `gjc: "repair-me"` or `gjc: { ralplan: [] }`) is malformed user
3525
+ // data; patching would silently replace it. Abort the migration,
3526
+ // mirroring the config-root guard, instead of treating the malformed
3527
+ // parent as an absent key.
3528
+ for (const patch of patches) {
3529
+ if (extractWorkflowSetting(tx.root, patch.path as WorkflowSettingKey, { flat: false }).malformedParent) {
3530
+ this.#warnLegacyFallbackMigration(
3531
+ `Settings: project workflow migration aborted: ${target} has a non-mapping parent for ${patch.path}; leaving untouched`,
3532
+ );
3533
+ return;
3534
+ }
3535
+ }
3536
+ // Revalidate the source BEFORE any side effect (evidence write, target
3537
+ // patch): evidence must never be written from bytes the source no
3538
+ // longer holds - an invalid-only migration returns right after the
3539
+ // evidence write, so a stale evidence file would make the direct
3540
+ // command exit 2 even after the source was repaired.
3541
+ const sourceChanged = async (): Promise<boolean> => {
3542
+ const currentRaw = await Bun.file(source)
3543
+ .text()
3544
+ .catch((error: unknown) => {
3545
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
3546
+ throw error;
3547
+ });
3548
+ return currentRaw !== raw;
3549
+ };
3550
+ if (await sourceChanged()) {
3551
+ this.#warnLegacyFallbackMigration(
3552
+ `Settings: project workflow migration aborted: ${source} changed while migrating; leaving untouched`,
3553
+ );
3554
+ return;
3555
+ }
3556
+ // A retained invalid strict ralplan key fails loudly only when the
3557
+ // project config.yml has no valid override for it (a valid target
3558
+ // value wins at the project layer). Decide the COMPLETE unresolved
3559
+ // set first, then write ONE evidence file carrying every unresolved
3560
+ // key - a per-key write-then-delete would let a later key's cleanup
3561
+ // drop an earlier key's evidence.
3562
+ const shadowedInvalidKeys: WorkflowSettingKey[] = [];
3563
+ const unresolved = invalidStrictKeys.filter(retained => {
3564
+ const targetValue = extractWorkflowSetting(tx.root, retained.key, { flat: false });
3565
+ const shadowed =
3566
+ !targetValue.malformedParent &&
3567
+ targetValue.present &&
3568
+ this.#workflowKeyValueIsValid(retained.key, targetValue.value);
3569
+ // A strict key shadowed by a valid project target is TOLERATED
3570
+ // (no exit 2 while the target wins), but it must still be
3571
+ // recorded as owned: the retained legacy source keeps the file
3572
+ // for non-workflow settings, and without ownership a later
3573
+ // `gjc config unset` of the target value would resurrect the
3574
+ // invalid legacy value and exit 2 (mirroring the config-root
3575
+ // path, where the source is retired so the value is gone).
3576
+ if (shadowed) shadowedInvalidKeys.push(retained.key);
3577
+ return !shadowed;
3578
+ });
3579
+ if (unresolved.length === 0) {
3580
+ await fs.promises
3581
+ .rm(this.#projectStrictInvalidEvidencePath(source), { force: true })
3582
+ .catch(() => undefined);
3583
+ } else {
3584
+ const evidenceOk = await this.#writeStrictInvalidEvidence(
3585
+ this.#projectStrictInvalidEvidencePath(source),
3586
+ source,
3587
+ unresolved,
3588
+ );
3589
+ if (!evidenceOk) {
3590
+ // Read-only fallback: persist the invalid values into the target
3591
+ // config.yml (the only surface the strict resolver reads) so the
3592
+ // exit-2 error stays observable when the evidence cannot be
3593
+ // written. A later valid legacy value repairs them (the absent
3594
+ // filter below treats a present-but-invalid target as absent).
3595
+ // Only ABSENT target keys get fallback values: a
3596
+ // present-but-invalid user-authored value already keeps exit-2
3597
+ // observable on its own, and overwriting it would destroy the
3598
+ // user's configuration when the fallback is later cleaned up or
3599
+ // rolled back.
3600
+ const fallbackEntries = unresolved.filter(retained => {
3601
+ const targetValue = extractWorkflowSetting(tx.root, retained.key, { flat: false });
3602
+ return !targetValue.malformedParent && !targetValue.present;
3603
+ });
3604
+ if (fallbackEntries.length > 0) {
3605
+ // Publish the ownership marker BEFORE the values: a crash between
3606
+ // the two leaves the marker (and no values), never values without
3607
+ // a marker the next load could not identify or clean up. Without
3608
+ // the marker, skip the values - an untracked fallback would exit
3609
+ // 2 forever with no way to clear it.
3610
+ const fallbackMarkerOk = await this.#writeFallbackInvalidKeys(
3611
+ source,
3612
+ await this.#mergeFallbackInvalidKeys(source, fallbackEntries),
3613
+ );
3614
+ if (fallbackMarkerOk) {
3615
+ this.#warnLegacyFallbackMigration(
3616
+ `Settings: project workflow migration persisted strict-invalid values into ${target} as fallback evidence`,
3617
+ );
3618
+ await tx.applyPatches(
3619
+ fallbackEntries.map(retained => ({
3620
+ path: retained.key,
3621
+ op: "set" as const,
3622
+ value: retained.value,
3623
+ })),
3624
+ );
3625
+ } else {
3626
+ this.#warnLegacyFallbackMigration(
3627
+ `Settings: project workflow migration could not track strict-invalid fallback values; skipping the fallback`,
3628
+ );
3629
+ }
3630
+ }
3631
+ }
3632
+ }
3633
+ absent = patches.filter(patch => {
3634
+ const targetValue = extractWorkflowSetting(tx.root, patch.path as WorkflowSettingKey, { flat: false });
3635
+ if (targetValue.malformedParent) return false;
3636
+ if (!targetValue.present) return true;
3637
+ // A present but INVALID target value (e.g. a fallback-written strict
3638
+ // value or an editor's mistake) is repairable: a valid legacy value
3639
+ // must win, mirroring the config-root migration's target repair.
3640
+ return !this.#workflowKeyValueIsValid(patch.path as WorkflowSettingKey, targetValue.value);
3641
+ });
3642
+ if (absent.length === 0) {
3643
+ // Every valid source key is already present in config.yml (e.g.
3644
+ // from an earlier run whose marker was lost): they are owned by
3645
+ // config.yml, so record them AND publish the merged marker under
3646
+ // the lock before returning - an all-present first load must still
3647
+ // create ownership, or a later `gjc config unset` would re-import
3648
+ // the stale legacy value.
3649
+ newlyOwned = [...patches.map(patch => patch.path as WorkflowSettingKey), ...shadowedInvalidKeys];
3650
+ const allPresentMarkerOk = await this.#writeProjectMigratedKeys(
3651
+ source,
3652
+ await this.#mergeProjectMigratedKeys(source, newlyOwned),
3653
+ );
3654
+ if (!allPresentMarkerOk) {
3655
+ // Ownership could not be durably recorded: without the marker, a
3656
+ // later `gjc config unset` would be undone by the retained
3657
+ // source. Nothing was published this run (every key was already
3658
+ // present), so there is nothing to roll back; surface the
3659
+ // failure instead of reporting silent completion - the next
3660
+ // load retries the marker publication.
3661
+ this.#warnLegacyFallbackMigration(
3662
+ `Settings: project workflow migration could not record ownership for ${source}; the migrated-keys marker was not written - a later config removal may be re-imported`,
3663
+ );
3664
+ }
3665
+ return;
3666
+ }
3667
+ // Re-verify immediately before the publication (the primary gate):
3668
+ // the resolver reads config.yml only, so a value edited between the
3669
+ // initial read and this write would be committed from stale bytes
3670
+ // and then ignored forever (later loads skip keys already present).
3671
+ if (await sourceChanged()) {
3672
+ this.#warnLegacyFallbackMigration(
3673
+ `Settings: project workflow migration aborted: ${source} changed while migrating; leaving untouched`,
3674
+ );
3675
+ return;
3676
+ }
3677
+ // Capture each key's pre-publication target state so a rollback can
3678
+ // RESTORE repaired keys (present-but-invalid values the migration
3679
+ // overwrites with the valid legacy value) instead of unsetting them -
3680
+ // an unset would silently delete the user's pre-existing
3681
+ // configuration. Keys that were ABSENT before the write are still
3682
+ // rolled back by unsetting.
3683
+ beforeStates = new Map<string, ProjectTargetBeforeState>();
3684
+ for (const patch of absent) {
3685
+ const current = extractWorkflowSetting(tx.root, patch.path as WorkflowSettingKey, { flat: false });
3686
+ beforeStates.set(patch.path, {
3687
+ present: !current.malformedParent && current.present,
3688
+ value: current.value,
3689
+ });
3690
+ }
3691
+ const restorePublished = (patches: readonly AtomicYamlPatch[]): AtomicYamlPatch[] =>
3692
+ patches.map(patch => {
3693
+ const before = beforeStates.get(patch.path);
3694
+ return before?.present
3695
+ ? { path: patch.path, op: "set" as const, value: before.value }
3696
+ : { path: patch.path, op: "unset" as const };
3697
+ });
3698
+ await tx.applyPatches(absent);
3699
+ // Close the residual publication window: if an editor replaced the
3700
+ // source DURING applyPatches (target CAS, temp write, native
3701
+ // publication), roll back the just-published keys so stale values
3702
+ // never survive in config.yml; the next load retries with the fresh
3703
+ // source. The evidence is cleared too (it was derived from the same
3704
+ // stale bytes).
3705
+ const rollbackMarkerlessPublication = async (
3706
+ reason: string,
3707
+ clearStrictEvidence: boolean,
3708
+ ): Promise<void> => {
3709
+ this.#warnLegacyFallbackMigration(
3710
+ `Settings: project workflow migration rolled back ${absent.length} written key(s): ${reason}`,
3711
+ );
3712
+ try {
3713
+ await tx.applyPatches(restorePublished(absent));
3714
+ } catch {
3715
+ // The rollback's whole-file CAS conflicted with an external
3716
+ // config.yml edit; recover AFTER the transaction by reopening
3717
+ // the target and restoring only values that still match the
3718
+ // migration write. An external target edit means the file is no
3719
+ // longer ours to remove.
3720
+ rollbackRequired = true;
3721
+ }
3722
+ if (targetWasAbsent && !rollbackRequired) {
3723
+ await this.#removeProjectMigrationCreatedTarget(tx.configPath, source);
3724
+ }
3725
+ // The strict-invalid evidence is cleared ONLY when the SOURCE
3726
+ // changed (it was derived from the stale bytes): an ownership-
3727
+ // marker failure leaves the source unchanged, so its evidence
3728
+ // stays valid and must keep making the exit-2 observable.
3729
+ if (clearStrictEvidence) {
3730
+ await fs.promises
3731
+ .rm(this.#projectStrictInvalidEvidencePath(source), { force: true })
3732
+ .catch(() => undefined);
3733
+ }
3734
+ };
3735
+ if (await sourceChanged()) {
3736
+ await rollbackMarkerlessPublication(`${source} changed during publication`, true);
3737
+ // The source changed while publishing: the rolled-back keys must
3738
+ // NOT be recorded as owned, or the newer legacy values would be
3739
+ // skipped forever. Stop the transaction callback here; the
3740
+ // post-transaction CAS recovery still runs for a conflicted
3741
+ // in-transaction rollback.
3742
+ return;
3743
+ }
3744
+ // Record EVERY config-owned key: the valid source keys copied by
3745
+ // this run and the pre-existing present-valid keys whose marker was
3746
+ // lost, plus strict keys shadowed by a valid project target (their
3747
+ // slot is owned by config.yml), so a later unset of any of them is
3748
+ // not re-imported from the retained legacy source.
3749
+ newlyOwned = [...patches.map(patch => patch.path as WorkflowSettingKey), ...shadowedInvalidKeys];
3750
+ // Merge the per-key ownership marker under the config lock: a
3751
+ // concurrent migration of the same project may have published its own
3752
+ // marker between our initial read and this transaction, so re-read the
3753
+ // CURRENT marker and merge instead of last-writer-wins replacement.
3754
+ let mergedMarker: readonly WorkflowSettingKey[];
3755
+ try {
3756
+ await SettingsMigrationTestHooks.beforeProjectMarkerMerge?.();
3757
+ mergedMarker = await this.#mergeProjectMigratedKeys(source, newlyOwned);
3758
+ } catch (error) {
3759
+ // The marker became unreadable between the initial read and this
3760
+ // re-read (AFTER the values already committed): ownership cannot
3761
+ // be durably recorded, so roll back the just-published keys
3762
+ // exactly like a failed marker write - a removed key would
3763
+ // otherwise be re-imported once the marker becomes readable
3764
+ // again.
3765
+ await rollbackMarkerlessPublication(
3766
+ `the migrated-keys marker could not be re-read: ${error instanceof Error ? error.message : String(error)}`,
3767
+ false,
3768
+ );
3769
+ return;
3770
+ }
3771
+ const markerOk = await this.#writeProjectMigratedKeys(source, mergedMarker);
3772
+ if (!markerOk) {
3773
+ // Ownership could not be durably recorded: without the marker, a
3774
+ // later config.yml removal would be undone by the retained source.
3775
+ // Undo only the keys THIS RUN wrote - restored repaired keys keep
3776
+ // their pre-existing value, newly copied keys are unset (a
3777
+ // markerless publication is never complete).
3778
+ await rollbackMarkerlessPublication("the migrated-keys marker could not be written", false);
3779
+ return;
3780
+ }
3781
+ });
3782
+ // A CAS-conflicted in-transaction rollback left the published stale
3783
+ // values in place; recover by reopening the target and unsetting only
3784
+ // the migration-published values that still match.
3785
+ if (rollbackRequired) {
3786
+ await this.#rollbackProjectPublishedKeys(target, absent, source, beforeStates);
3787
+ }
3788
+ logger.debug("Settings: migrated project workflow settings into config.yml", { path: target });
3789
+ } catch (error) {
3790
+ // A malformed project config.yml must not abort settings load: the
3791
+ // resolver's own diagnostics still run afterwards, and the legacy
3792
+ // source stays untouched for the next load to retry.
3793
+ this.#warnLegacyFallbackMigration(
3794
+ `Settings: project workflow migration could not write ${target}: ${error instanceof Error ? error.message : String(error)}; leaving untouched`,
3795
+ );
3796
+ }
3797
+ }
3798
+
3799
+ /**
3800
+ * Remove a project config.yml that THIS migration run created and then
3801
+ * rolled back. The rollback unsets the published keys but leaves an empty
3802
+ * file behind; because the resolver treats any existing non-future target as
3803
+ * authoritative, that empty file would disable the retained settings.json
3804
+ * fallback. Removing it restores the incomplete-migration state so the next
3805
+ * load (and every retry while the marker stays unwritable) keeps resolving
3806
+ * through the fallback. Only ever called under the target's own file lock
3807
+ * right after the rollback write, and the removal itself is identity-guarded
3808
+ * (an external editor that does not participate in the lock can atomically
3809
+ * replace the file, and that replacement must never be deleted).
3810
+ */
3811
+ async #removeProjectMigrationCreatedTarget(canonicalTarget: string, source: string): Promise<void> {
3812
+ if (await this.#removeFileOnlyIfUnchanged(canonicalTarget)) {
3813
+ this.#warnLegacyFallbackMigration(
3814
+ `Settings: project workflow migration removed the config.yml it created during rollback so the retained ${source} fallback stays active`,
3815
+ );
3816
+ return;
3817
+ }
3818
+ this.#warnLegacyFallbackMigration(
3819
+ `Settings: project workflow migration could not remove the config.yml it created during rollback; a replaced or retained file is left in place`,
3820
+ );
3821
+ }
3822
+
3823
+ /**
3824
+ * Remove a file only when it is still the exact file observed here. An
3825
+ * external process (an editor that ignores the config lock, or a concurrent
3826
+ * loader publishing evidence) can atomically replace a path between this
3827
+ * run's decision and a pathname removal, so the file is first renamed to a
3828
+ * unique tombstone in the same directory (atomic), the moved file's inode
3829
+ * identity is verified against the observation, and only a matching file is
3830
+ * unlinked. A replaced file is renamed back to its path, never deleted.
3831
+ * Returns true when the observed file is gone (removed or already absent),
3832
+ * false when a replaced or immovable file was left in place.
3833
+ */
3834
+ async #removeFileOnlyIfUnchanged(filePath: string): Promise<boolean> {
3835
+ let expected: { dev: bigint; ino: bigint };
3836
+ try {
3837
+ const stat = await fs.promises.stat(filePath, { bigint: true });
3838
+ expected = { dev: stat.dev, ino: stat.ino };
3839
+ } catch {
3840
+ return true; // already gone: nothing to remove
3841
+ }
3842
+ const tombstone = `${filePath}.gjc-remove-${nodeCrypto.randomUUID()}`;
3843
+ try {
3844
+ await fs.promises.rename(filePath, tombstone);
3845
+ } catch (error) {
3846
+ if (isEnoent(error)) return true;
3847
+ return false; // could not move: leave in place
3848
+ }
3849
+ let moved: { dev: bigint; ino: bigint } | null = null;
3850
+ try {
3851
+ const stat = await fs.promises.lstat(tombstone, { bigint: true });
3852
+ moved = { dev: stat.dev, ino: stat.ino };
3853
+ } catch {
3854
+ return true; // the moved file already disappeared
3855
+ }
3856
+ if (moved.dev !== expected.dev || moved.ino !== expected.ino) {
3857
+ // The rename moved a file an external process replaced after the
3858
+ // identity capture: restore it WITHOUT replacing a file published
3859
+ // while the path was absent (hard-link no-replace; EEXIST keeps the
3860
+ // newest file and retains the tombstone for recovery).
3861
+ try {
3862
+ await fs.promises.link(tombstone, filePath);
3863
+ await fs.promises.rm(tombstone, { force: true }).catch(() => undefined);
3864
+ } catch {
3865
+ // The path is occupied (EEXIST) or linking failed: keep the newest
3866
+ // file and retain the tombstone (recoverable).
3867
+ }
3868
+ return false;
3869
+ }
3870
+ await fs.promises.rm(tombstone, { force: true }).catch(() => undefined);
3871
+ return true;
3872
+ }
3873
+
3874
+ /**
3875
+ * Project strict-invalid evidence lives under the ignored runtime dir
3876
+ * `.gjc/state/` so running GJC never dirties the user's git worktree; the
3877
+ * config-root evidence stays next to its machine-global source (outside any
3878
+ * repository).
3879
+ */
3880
+ #projectStrictInvalidEvidencePath(source: string): string {
3881
+ return path.join(path.dirname(source), "state", "settings.json.strict-invalid");
3882
+ }
3883
+
3884
+ /**
3885
+ * Record malformed-source strict evidence for the project migration; when the
3886
+ * evidence sidecar (`.gjc/state/`) cannot be written, fall back to persisting
3887
+ * invalid placeholder values into the project config.yml so the ralplan exit-2
3888
+ * error stays observable either way.
3889
+ */
3890
+ async #recordProjectMalformedEvidence(source: string, target: string): Promise<void> {
3891
+ // Revalidate the source revision right before publishing: a repair or
3892
+ // replacement after the read/parse failure must not leave a stale global
3893
+ // malformed marker - the config-only resolver would exit 2 for an
3894
+ // already-valid source.
3895
+ if (!(await this.#sourceStillMalformed(source, null))) return;
3896
+ // When EVERY strict ralplan key is already owned by config.yml (recorded
3897
+ // in the migrated-keys marker), the retained source's corruption is
3898
+ // irrelevant: a deliberate `gjc config unset` of an owned key must keep
3899
+ // falling through to the lower layer/default instead of exiting 2 on a
3900
+ // stale global malformed marker. Partial ownership still publishes the
3901
+ // marker (the unowned strict keys' errors must stay observable).
3902
+ if (await this.#allProjectStrictKeysMigrated(source)) return;
3903
+ if (
3904
+ await this.#writeStrictInvalidEvidence(this.#projectStrictInvalidEvidencePath(source), source, [], {
3905
+ malformed: true,
3906
+ })
3907
+ ) {
3908
+ return;
3909
+ }
3910
+ await this.#persistProjectMalformedStrictFallback(source, target);
3911
+ }
3912
+
3913
+ /**
3914
+ * True when every strict ralplan key the project source could contribute is
3915
+ * already recorded in the project migrated-keys ownership marker.
3916
+ */
3917
+ async #allProjectStrictKeysMigrated(source: string): Promise<boolean> {
3918
+ const owned = await this.#readProjectMigratedKeys(source);
3919
+ return CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS.filter(key => key.startsWith("gjc.ralplan.")).every(key =>
3920
+ owned.has(key),
3921
+ );
3922
+ }
3923
+
3924
+ /**
3925
+ * Revalidate that the retained project source is STILL malformed in the same
3926
+ * sense the caller observed (unreadable, unparseable, non-object root, or a
3927
+ * non-mapping strict workflow parent) before recording malformed evidence.
3928
+ */
3929
+ /**
3930
+ * True when the retained legacy source is still malformed in the strict
3931
+ * sense: unreadable (non-ENOENT), unparseable, a non-object root, or a
3932
+ * non-mapping strict workflow parent. Used to revalidate a source before
3933
+ * publishing malformed evidence - a repair must not leave a stale marker,
3934
+ * but a structurally malformed revision (e.g. `null`, `[]`, or a broken
3935
+ * `gjc.ralplan` parent) parses as JSON and is NOT a repair.
3936
+ *
3937
+ * Pass `knownRaw` when the bytes were already read to skip the re-read.
3938
+ */
3939
+ async #sourceStillMalformed(source: string, knownRaw: string | null): Promise<boolean> {
3940
+ let raw: string;
3941
+ if (knownRaw !== null) {
3942
+ raw = knownRaw;
3943
+ } else {
3944
+ try {
3945
+ raw = await Bun.file(source).text();
3946
+ } catch (error) {
3947
+ return !isEnoent(error); // removed: nothing to record
3948
+ }
3949
+ }
3950
+ let document: unknown;
3951
+ try {
3952
+ document = JSON.parse(raw);
3953
+ } catch {
3954
+ return true; // still unparseable
3955
+ }
3956
+ if (!document || typeof document !== "object" || Array.isArray(document)) return true;
3957
+ for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
3958
+ if (!key.startsWith("gjc.ralplan.")) continue;
3959
+ if (extractWorkflowSetting(document as Record<string, unknown>, key).malformedParent) return true;
3960
+ }
3961
+ return false; // parses to a valid object with well-formed parents
3962
+ }
3963
+
3964
+ /**
3965
+ * Fallback for a malformed or unreadable legacy project source whose strict
3966
+ * evidence sidecar could not be written: persist guaranteed-invalid placeholder
3967
+ * values for the strict ralplan keys into the project config.yml (the only
3968
+ * surface the strict resolver reads) so `gjc ralplan` keeps exiting 2 while
3969
+ * the source is malformed. The fallback is tracked by the fallback-invalid
3970
+ * marker (writable next to the source even when `.gjc/state/` is not) and is
3971
+ * removed once the source is repaired.
3972
+ */
3973
+ async #persistProjectMalformedStrictFallback(source: string, target: string): Promise<void> {
3974
+ try {
3975
+ await withAtomicYamlConfigTransaction(target, async tx => {
3976
+ await this.#persistMalformedStrictFallbackInTx(tx, source);
3977
+ });
3978
+ } catch (error) {
3979
+ this.#warnLegacyFallbackMigration(
3980
+ `Settings: project workflow migration could not persist malformed-source strict fallback into ${target}: ${error instanceof Error ? error.message : String(error)}`,
3981
+ );
3982
+ }
3983
+ }
3984
+
3985
+ /**
3986
+ * Record malformed-source strict evidence for the config-root migration and,
3987
+ * when the evidence sidecar (`${source}.strict-invalid`) cannot be written,
3988
+ * fall back to persisting invalid placeholders into the target agent config.yml
3989
+ * inside the CURRENT transaction (the config-root malformed branches run under
3990
+ * the target lock, so a nested transaction would deadlock the queue).
3991
+ */
3992
+ async #recordConfigRootMalformedEvidence(tx: AtomicYamlConfigTransaction, source: string): Promise<void> {
3993
+ // Revalidate the source revision right before publishing: a repair or
3994
+ // replacement after the caller's read/hash check must not leave a stale
3995
+ // global malformed marker (or invalid fallback placeholders) - the
3996
+ // config-only resolver would exit 2 for an already-valid source.
3997
+ if (!(await this.#sourceStillMalformed(source, null))) return;
3998
+ if (await this.#writeStrictInvalidEvidence(`${source}.strict-invalid`, source, [], { malformed: true })) {
3999
+ return;
4000
+ }
4001
+ await this.#persistMalformedStrictFallbackInTx(tx, source, this.#configRootFallbackInvalidMarkerPath(source));
4002
+ }
4003
+
4004
+ /**
4005
+ * Persist guaranteed-invalid placeholder values for the strict ralplan keys
4006
+ * into the target config.yml inside an already-held transaction. Mirrors the
4007
+ * main migrations' target guards (never patch a future schema or a malformed
4008
+ * root) and never overwrites a PRESENT target value: a valid value wins
4009
+ * outright, and an invalid value already keeps the exit-2 error observable on
4010
+ * its own - replacing it with a placeholder (and later unsetting it on marker
4011
+ * failure or repair) would destroy the user's pre-existing configuration.
4012
+ */
4013
+ async #persistMalformedStrictFallbackInTx(
4014
+ tx: AtomicYamlConfigTransaction,
4015
+ source: string,
4016
+ markerPath = `${source}.fallback-invalid`,
4017
+ ): Promise<void> {
4018
+ const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
4019
+ if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) return;
4020
+ if (tx.root !== undefined && (tx.root === null || typeof tx.root !== "object" || Array.isArray(tx.root))) {
4021
+ return;
4022
+ }
4023
+ const placeholders: StrictInvalidEvidenceEntry[] = [];
4024
+ for (const { key, value } of MALFORMED_SOURCE_STRICT_FALLBACK) {
4025
+ const current = extractWorkflowSetting(tx.root, key, { flat: false });
4026
+ if (current.malformedParent) continue;
4027
+ if (current.present) continue;
4028
+ placeholders.push({ key, value });
4029
+ }
4030
+ if (placeholders.length === 0) return;
4031
+ // Publish the ownership marker BEFORE the values: a crash between the two
4032
+ // leaves the marker (and no values), never values without a marker the next
4033
+ // load could not identify or clean up. Without the marker, skip the values -
4034
+ // an untracked fallback would exit 2 forever with no way to clear it.
4035
+ const markerOk = await this.#writeFallbackInvalidKeys(
4036
+ source,
4037
+ await this.#mergeFallbackInvalidKeys(source, placeholders, markerPath),
4038
+ markerPath,
4039
+ );
4040
+ if (!markerOk) {
4041
+ this.#warnLegacyFallbackMigration(
4042
+ `Settings: workflow migration could not track malformed-source strict fallback values; skipping the fallback`,
4043
+ );
4044
+ return;
4045
+ }
4046
+ await tx.applyPatches(placeholders.map(entry => ({ path: entry.key, op: "set" as const, value: entry.value })));
4047
+ }
4048
+
4049
+ /**
4050
+ * Persist strict-invalid retention evidence: when a migration aborts on an
4051
+ * invalid STRICT ralplan legacy value (or a malformed source) it keeps the
4052
+ * source active so `gjc ralplan` can still fail loudly, but the resolver
4053
+ * never reads the retained source. This evidence file is the durable record
4054
+ * the resolver's strict path throws on, so the retained value is surfaced
4055
+ * instead of silently falling back to defaults. The file carries the complete
4056
+ * set of unresolved strict keys (or a malformed-source marker) so one file
4057
+ * cannot shadow another key's evidence. Returns false when the evidence could
4058
+ * not be written (callers must keep the strict error observable some other
4059
+ * way, e.g. by persisting the invalid values into config.yml).
4060
+ */
4061
+ async #writeStrictInvalidEvidence(
4062
+ evidencePath: string,
4063
+ source: string,
4064
+ entries: readonly StrictInvalidEvidenceEntry[],
4065
+ options: { malformed?: boolean } = {},
4066
+ ): Promise<boolean> {
4067
+ const payload = options.malformed
4068
+ ? JSON.stringify({ version: 2, malformed: true, source: path.resolve(source) }, null, 2)
4069
+ : JSON.stringify(
4070
+ {
4071
+ version: 2,
4072
+ keys: entries.map(entry => ({ key: entry.key, value: entry.value })),
4073
+ source: path.resolve(source),
4074
+ },
4075
+ null,
4076
+ 2,
4077
+ );
4078
+ // Publish atomically (stage + sync + rename): a direct in-place write
4079
+ // could truncate the live evidence file before the replacement payload
4080
+ // is durable, and readRetainedStrictEvidence would then read malformed
4081
+ // JSON as no evidence - the still-invalid strict key would silently fall
4082
+ // through to defaults instead of preserving exit 2.
4083
+ const tempPath = `${evidencePath}.${nodeCrypto.randomUUID()}.tmp`;
4084
+ try {
4085
+ await fs.promises.mkdir(path.dirname(evidencePath), { recursive: true });
4086
+ const tempHandle = await fs.promises.open(tempPath, "wx", 0o600);
4087
+ try {
4088
+ await tempHandle.writeFile(payload, "utf8");
4089
+ await tempHandle.sync();
4090
+ } finally {
4091
+ await tempHandle.close();
4092
+ }
4093
+ await fs.promises.rename(tempPath, evidencePath);
4094
+ // Durable publication: sync the parent directory so the rename survives
4095
+ // a host crash. Best-effort: platforms/filesystems that reject directory
4096
+ // sync are ignored.
4097
+ try {
4098
+ const dirHandle = await fs.promises.open(path.dirname(evidencePath), "r");
4099
+ try {
4100
+ await dirHandle.sync();
4101
+ } finally {
4102
+ await dirHandle.close();
4103
+ }
4104
+ } catch {
4105
+ // Unsupported directory sync: ignore.
4106
+ }
4107
+ return true;
4108
+ } catch (error) {
4109
+ await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
4110
+ this.#warnLegacyFallbackMigration(
4111
+ `Settings: workflow migration could not write strict-invalid evidence at ${evidencePath}: ${error instanceof Error ? error.message : String(error)}`,
4112
+ );
4113
+ return false;
4114
+ }
4115
+ }
4116
+
4117
+ /**
4118
+ * Read strict-invalid retention evidence. Returns null when absent or
4119
+ * structurally invalid; an evidence file with no recorded key for the
4120
+ * relevant workflow key is treated as null so it never affects other keys.
4121
+ */
4122
+ async #readStrictInvalidEvidence(evidencePath: string): Promise<StrictInvalidEvidence | null> {
4123
+ let raw: string;
4124
+ try {
4125
+ raw = await Bun.file(evidencePath).text();
4126
+ } catch {
4127
+ return null;
4128
+ }
4129
+ try {
4130
+ const parsed = JSON.parse(raw) as {
4131
+ version?: unknown;
4132
+ malformed?: unknown;
4133
+ key?: unknown;
4134
+ value?: unknown;
4135
+ keys?: unknown;
4136
+ source?: unknown;
4137
+ };
4138
+ const source = typeof parsed.source === "string" ? parsed.source : evidencePath;
4139
+ if (parsed.malformed === true) {
4140
+ return { version: 2, malformed: true, source };
4141
+ }
4142
+ if (parsed.version === 2 && Array.isArray(parsed.keys)) {
4143
+ const keys: StrictInvalidEvidenceEntry[] = [];
4144
+ for (const rawEntry of parsed.keys) {
4145
+ if (!rawEntry || typeof rawEntry !== "object") continue;
4146
+ const entry = rawEntry as { key?: unknown; value?: unknown };
4147
+ if (
4148
+ typeof entry.key === "string" &&
4149
+ (CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(entry.key)
4150
+ ) {
4151
+ keys.push({ key: entry.key as WorkflowSettingKey, value: entry.value });
4152
+ }
4153
+ }
4154
+ if (keys.length === 0) return null;
4155
+ return { version: 2, keys, source };
4156
+ }
4157
+ // v1 single-key compatibility.
4158
+ if (
4159
+ typeof parsed.key === "string" &&
4160
+ (CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(parsed.key)
4161
+ ) {
4162
+ return {
4163
+ version: 2,
4164
+ keys: [{ key: parsed.key as WorkflowSettingKey, value: parsed.value }],
4165
+ source,
4166
+ };
4167
+ }
4168
+ return null;
4169
+ } catch {
4170
+ return null;
4171
+ }
4172
+ }
4173
+
4174
+ /**
4175
+ * Prune strict-invalid retention evidence to the keys that STILL hold in the
4176
+ * source. A v2 file may record several unresolved ralplan keys; repairing ONE
4177
+ * of them must not drop the evidence for the others (their exit-2 would
4178
+ * silently vanish), so the file is rewritten with only the still-invalid
4179
+ * entries and removed entirely only when none remain. Malformed-source
4180
+ * evidence is all-or-nothing: any parseable source (a repair) clears it. A
4181
+ * transient source read failure throws so callers retain the evidence.
4182
+ */
4183
+ async #pruneStrictInvalidEvidence(
4184
+ source: string,
4185
+ evidencePath: string,
4186
+ evidence: StrictInvalidEvidence,
4187
+ ): Promise<void> {
4188
+ let raw: string;
4189
+ try {
4190
+ raw = await Bun.file(source).text();
4191
+ } catch (error) {
4192
+ // Only a genuine removal (ENOENT) clears the evidence; a transient
4193
+ // read failure (EACCES, EIO) must NOT clear it, or the strict error
4194
+ // would silently vanish with neither source nor evidence to surface
4195
+ // it. Throw so callers retain the evidence.
4196
+ if (isEnoent(error)) {
4197
+ await fs.promises.rm(evidencePath, { force: true }).catch(() => undefined);
4198
+ return;
4199
+ }
4200
+ throw error;
4201
+ }
4202
+ if (evidence.malformed) {
4203
+ // The malformed marker holds while the source is still malformed; a
4204
+ // parseable source (a repair) clears it entirely.
4205
+ try {
4206
+ JSON.parse(raw);
4207
+ } catch {
4208
+ return;
4209
+ }
4210
+ await fs.promises.rm(evidencePath, { force: true }).catch(() => undefined);
4211
+ return;
4212
+ }
4213
+ let document: unknown;
4214
+ try {
4215
+ document = JSON.parse(raw);
4216
+ } catch {
4217
+ // The source became malformed: keep the recorded key evidence; the
4218
+ // same run's malformed branch re-records it as a malformed marker.
4219
+ return;
4220
+ }
4221
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
4222
+ // Structurally invalid root: the recorded values cannot be verified;
4223
+ // keep the evidence (the source is still invalid per the resolver).
4224
+ return;
4225
+ }
4226
+ const stillHolding = evidence.keys.filter(entry => {
4227
+ const extracted = extractWorkflowSetting(document as Record<string, unknown>, entry.key);
4228
+ if (!extracted.present) return false;
4229
+ if (JSON.stringify(extracted.value) !== JSON.stringify(entry.value)) return false;
4230
+ return !this.#workflowKeyValueIsValid(entry.key, extracted.value);
4231
+ });
4232
+ const clearedKeys = new Set(evidence.keys.filter(entry => !stillHolding.includes(entry)).map(entry => entry.key));
4233
+ // Re-read the CURRENT sidecar right before writing and reconcile with a
4234
+ // concurrent publication: remove only the entries this run cleared, while
4235
+ // keeping every still-invalid key AND every concurrently-added key (a
4236
+ // stale A-only removal must never drop a newer sidecar that added B). The
4237
+ // callers additionally hold the target lock, so publications of the same
4238
+ // source serialize with this prune.
4239
+ const final = new Map<WorkflowSettingKey, unknown>();
4240
+ for (const entry of stillHolding) final.set(entry.key, entry.value);
4241
+ const current = await this.#readStrictInvalidEvidence(evidencePath);
4242
+ if (current?.malformed) {
4243
+ // A concurrent loader published a malformed-source marker (the source is
4244
+ // now malformed): retain it - it is the stronger evidence, and deleting
4245
+ // it here would let the resolver silently fall through to defaults. The
4246
+ // next load's prune clears it once the source is parseable again.
4247
+ return;
4248
+ }
4249
+ if (current) {
4250
+ for (const entry of current.keys) {
4251
+ if (!clearedKeys.has(entry.key)) final.set(entry.key, entry.value);
4252
+ }
4253
+ }
4254
+ if (final.size === 0) {
4255
+ // Delete only the exact evidence file this run reconciled: a concurrent
4256
+ // loader may publish a NEW malformed marker after the re-read above, and
4257
+ // a pathname rm would delete that marker (letting a strict resolver with
4258
+ // an existing keyless project config.yml fall through instead of exiting
4259
+ // 2). The rename + inode-verify removal deletes only the file observed
4260
+ // here; a concurrently-published marker is renamed back and survives.
4261
+ await this.#removeFileOnlyIfUnchanged(evidencePath);
4262
+ return;
4263
+ }
4264
+ const finalEntries = [...final].map(([key, value]) => ({ key, value }));
4265
+ if (
4266
+ finalEntries.length === evidence.keys.length &&
4267
+ finalEntries.every((entry, index) => entry.key === evidence.keys[index].key)
4268
+ ) {
4269
+ return; // unchanged (a concurrent run already reconciled)
4270
+ }
4271
+ await this.#writeStrictInvalidEvidence(evidencePath, source, finalEntries);
4272
+ }
4273
+
4274
+ /**
4275
+ * Retain strict errors from a retained legacy source when the migration must
4276
+ * not touch the target (future-schema guard): the resolver reads config.yml
4277
+ * only, so an invalid strict ralplan value or a malformed source must be
4278
+ * recorded as evidence to keep `gjc ralplan` exiting 2 instead of silently
4279
+ * defaulting. Clears stale evidence when the source holds no strict failure.
4280
+ */
4281
+ async #retainStrictErrorsFromSource(source: string, evidencePath: string): Promise<boolean> {
4282
+ const recordMalformed = async (): Promise<boolean> => {
4283
+ // Revalidate the source revision right before publishing: a repair or
4284
+ // replacement between the initial read/parse failure and this write
4285
+ // must not leave a stale malformed marker - the workflow command's
4286
+ // config-only resolver would otherwise exit 2 for an already-repaired
4287
+ // source, and a concurrent key-specific publication could be
4288
+ // overwritten by this global marker.
4289
+ let latest: string;
4290
+ try {
4291
+ latest = await Bun.file(source).text();
4292
+ } catch (error) {
4293
+ // Removed (ENOENT): nothing to record. Still unreadable: keep the
4294
+ // strict error observable via the malformed marker.
4295
+ if (isEnoent(error)) return true;
4296
+ const ok = await this.#writeStrictInvalidEvidence(evidencePath, source, [], { malformed: true });
4297
+ if (!ok) this.#warnEvidenceNotPreserved(source);
4298
+ return ok;
4299
+ }
4300
+ // Shape-check, not just parseability: a structurally malformed revision
4301
+ // (null/array root, non-mapping strict parent) parses as JSON but is
4302
+ // NOT a repair - only a valid object with well-formed parents is.
4303
+ if (!(await this.#sourceStillMalformed(source, latest))) {
4304
+ return true; // repaired since the initial failure: no malformed marker
4305
+ }
4306
+ // Still malformed: record it.
4307
+ const ok = await this.#writeStrictInvalidEvidence(evidencePath, source, [], { malformed: true });
4308
+ if (!ok) this.#warnEvidenceNotPreserved(source);
4309
+ return ok;
4310
+ };
4311
+ // Re-read and recompute up to twice: a repair or replacement during the
4312
+ // scan must not publish evidence computed from stale bytes.
4313
+ for (let attempt = 0; attempt < 2; attempt++) {
4314
+ let raw: string;
4315
+ try {
4316
+ raw = await Bun.file(source).text();
4317
+ } catch (error) {
4318
+ if (isEnoent(error)) return true;
4319
+ // An EXISTING but unreadable source (EACCES, EIO) is a
4320
+ // malformed-source strict failure.
4321
+ return await recordMalformed();
4322
+ }
4323
+ let document: unknown;
4324
+ try {
4325
+ document = JSON.parse(raw);
4326
+ } catch {
4327
+ return await recordMalformed();
4328
+ }
4329
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
4330
+ return await recordMalformed();
4331
+ }
4332
+ const invalidStrict: StrictInvalidEvidenceEntry[] = [];
4333
+ for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
4334
+ if (!key.startsWith("gjc.ralplan.")) continue;
4335
+ const extracted = extractWorkflowSetting(document as Record<string, unknown>, key);
4336
+ if (extracted.malformedParent) {
4337
+ // A non-mapping parent for a strict key is a malformed-source
4338
+ // strict failure (exit 2).
4339
+ return await recordMalformed();
4340
+ }
4341
+ if (!extracted.present) continue;
4342
+ if (!this.#workflowKeyValueIsValid(key, this.#coerceWorkflowScalar(key, extracted.value))) {
4343
+ invalidStrict.push({ key, value: extracted.value });
4344
+ }
4345
+ }
4346
+ // Revalidate the source revision right before publishing key-specific
4347
+ // evidence: a repair or replacement during the scan must not leave
4348
+ // evidence that makes the resolver exit 2 for a now-valid source.
4349
+ const latestRaw = await Bun.file(source)
4350
+ .text()
4351
+ .catch(() => null);
4352
+ if (latestRaw === null) {
4353
+ // Source removed: clear any stale evidence.
4354
+ await fs.promises.rm(evidencePath, { force: true }).catch(() => undefined);
4355
+ return true;
4356
+ }
4357
+ if (latestRaw !== raw) continue; // changed while scanning: recompute
4358
+ if (invalidStrict.length === 0) {
4359
+ // The source holds no strict failure; clear any stale evidence.
4360
+ await fs.promises.rm(evidencePath, { force: true }).catch(() => undefined);
4361
+ return true;
4362
+ }
4363
+ const ok = await this.#writeStrictInvalidEvidence(evidencePath, source, invalidStrict);
4364
+ if (!ok) this.#warnEvidenceNotPreserved(source);
4365
+ return ok;
4366
+ }
4367
+ // The source kept changing across both reads; publish nothing rather than
4368
+ // stale evidence.
4369
+ return true;
4370
+ }
4371
+
4372
+ /**
4373
+ * The read-only future-schema target cannot receive fallback placeholders and
4374
+ * the evidence sidecar is unwritable, so the strict error cannot be preserved
4375
+ * on any durable surface; surface the failure instead of silently letting
4376
+ * `gjc ralplan` fall through to defaults.
4377
+ */
4378
+ #warnEvidenceNotPreserved(source: string): void {
4379
+ this.#warnLegacyFallbackMigration(
4380
+ `Settings: strict error could not be preserved for ${source} (evidence unwritable, target read-only); gjc ralplan will use defaults until the source is repaired or the evidence path is writable`,
4381
+ );
4382
+ }
4383
+
4384
+ /**
4385
+ * Read the project workflow migration's per-key completion marker. A key in
4386
+ * this set is owned by config.yml and is never re-imported from the retained
4387
+ * `.gjc/settings.json`, so removing a migrated override from config.yml
4388
+ * sticks. The marker lives under the ignored runtime dir `.gjc/state/` so a
4389
+ * successful migration never dirties the user's git worktree. A missing or
4390
+ * malformed marker reads as empty (the migration simply re-copies absent-only
4391
+ * values and rewrites the marker).
4392
+ */
4393
+ async #readProjectMigratedKeys(source: string): Promise<Set<WorkflowSettingKey>> {
4394
+ const markerPath = path.join(path.dirname(source), "state", "settings.json.migrated-keys");
4395
+ let raw: string;
4396
+ try {
4397
+ raw = await Bun.file(markerPath).text();
4398
+ } catch (error) {
4399
+ // Only a MISSING marker reads as empty. A non-ENOENT read failure
4400
+ // (EACCES, transient I/O) must ABORT the migration: treating it as
4401
+ // empty would reimport a stale retained value and overwrite the
4402
+ // marker once the failure clears.
4403
+ if (isEnoent(error)) return new Set();
4404
+ throw error;
4405
+ }
4406
+ try {
4407
+ const parsed = JSON.parse(raw) as unknown;
4408
+ if (!Array.isArray(parsed)) return new Set();
4409
+ const keys = new Set<WorkflowSettingKey>();
4410
+ for (const entry of parsed) {
4411
+ if (
4412
+ typeof entry === "string" &&
4413
+ (CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(entry)
4414
+ ) {
4415
+ keys.add(entry as WorkflowSettingKey);
4416
+ }
4417
+ }
4418
+ return keys;
4419
+ } catch {
4420
+ return new Set();
4421
+ }
4422
+ }
4423
+
4424
+ /**
4425
+ * Atomically persist the project workflow migration's per-key completion
4426
+ * marker under `.gjc/state/` (temp file + rename; the directory is created on
4427
+ * demand).
4428
+ */
4429
+ async #writeProjectMigratedKeys(source: string, keys: readonly WorkflowSettingKey[]): Promise<boolean> {
4430
+ const markerPath = path.join(path.dirname(source), "state", "settings.json.migrated-keys");
4431
+ // A UNIQUE staging path: two GJC processes may migrate the same project
4432
+ // concurrently, and a fixed .tmp name could be consumed by one writer's
4433
+ // rename, making the other fail its marker write and roll back a copy
4434
+ // whose ownership the winner recorded.
4435
+ const tempPath = `${markerPath}.${nodeCrypto.randomUUID()}.tmp`;
4436
+ try {
4437
+ await fs.promises.mkdir(path.dirname(markerPath), { recursive: true });
4438
+ // Fsync the staged payload BEFORE the rename: Bun.write alone would let a
4439
+ // host/power failure after publication leave an empty or malformed marker
4440
+ // whose directory entry survives the parent sync but whose content does
4441
+ // not, and #readProjectMigratedKeys would read no ownership - a later
4442
+ // unset of a migrated key would then re-import the stale legacy value.
4443
+ const tempHandle = await fs.promises.open(tempPath, "wx", 0o600);
4444
+ try {
4445
+ await tempHandle.writeFile(JSON.stringify(keys, null, 2), "utf8");
4446
+ await tempHandle.sync();
4447
+ } finally {
4448
+ await tempHandle.close();
4449
+ }
4450
+ await fs.promises.rename(tempPath, markerPath);
4451
+ // Durable publication: sync the marker's parent directory so the rename
4452
+ // (the ownership entry) survives a host crash even when the temp
4453
+ // content was already durable. Best-effort: platforms/filesystems that
4454
+ // reject directory sync are ignored.
4455
+ try {
4456
+ const dirHandle = await fs.promises.open(path.dirname(markerPath), "r");
4457
+ try {
4458
+ await dirHandle.sync();
4459
+ } finally {
4460
+ await dirHandle.close();
4461
+ }
4462
+ } catch {
4463
+ // Unsupported directory sync: ignore.
4464
+ }
4465
+ return true;
4466
+ } catch (error) {
4467
+ await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
4468
+ this.#warnLegacyFallbackMigration(
4469
+ `Settings: project workflow migration could not write the migrated-keys marker at ${markerPath}: ${error instanceof Error ? error.message : String(error)}`,
4470
+ );
4471
+ return false;
4472
+ }
4473
+ }
4474
+
4475
+ /**
4476
+ * Second-chance rollback for the project migration: when the in-transaction
4477
+ * rollback lost its whole-file CAS (an editor changed an unrelated config.yml
4478
+ * field between the publication and the rollback), reopen the target and
4479
+ * restore only the migration-published values that still match what the
4480
+ * migration wrote - never an editor's newer value. Repaired keys get their
4481
+ * pre-publication value back; keys that were absent before are unset. Also
4482
+ * used when the migrated-key marker cannot be written, so a markerless
4483
+ * publication is never considered complete.
4484
+ */
4485
+ async #rollbackProjectPublishedKeys(
4486
+ target: string,
4487
+ published: readonly AtomicYamlPatch[],
4488
+ source: string,
4489
+ beforeStates: ReadonlyMap<string, ProjectTargetBeforeState>,
4490
+ ): Promise<void> {
4491
+ try {
4492
+ await withAtomicYamlConfigTransaction(target, async tx => {
4493
+ const matching = published.filter(patch => {
4494
+ if (patch.op !== "set") return false;
4495
+ const current = extractWorkflowSetting(tx.root, patch.path as WorkflowSettingKey, { flat: false });
4496
+ return (
4497
+ !current.malformedParent &&
4498
+ current.present &&
4499
+ JSON.stringify(current.value) === JSON.stringify(patch.value)
4500
+ );
4501
+ });
4502
+ if (matching.length === 0) return;
4503
+ // Restore each key's pre-publication state instead of blanket-unsetting:
4504
+ // a repaired key (present-but-invalid before the migration) must keep
4505
+ // its original value rather than being deleted.
4506
+ await tx.applyPatches(
4507
+ matching.map(patch => {
4508
+ const before = beforeStates.get(patch.path);
4509
+ return before?.present
4510
+ ? { path: patch.path, op: "set" as const, value: before.value }
4511
+ : { path: patch.path, op: "unset" as const };
4512
+ }),
4513
+ );
4514
+ });
4515
+ // Clear the strict-invalid evidence ONLY when it no longer reflects the
4516
+ // retained source: independently valid evidence (the source still holds
4517
+ // the recorded invalid values) must survive the rollback so exit 2 stays
4518
+ // observable - a marker-failure rollback does not change the source.
4519
+ const evidencePath = this.#projectStrictInvalidEvidencePath(source);
4520
+ const retainedEvidence = await this.#readStrictInvalidEvidence(evidencePath);
4521
+ if (retainedEvidence) {
4522
+ try {
4523
+ // Prune to the still-invalid keys: repairing ONE recorded key must
4524
+ // not drop the evidence for the remaining unresolved keys. Runs
4525
+ // under the target lock so a concurrent process's evidence
4526
+ // publication serializes with this prune.
4527
+ await withAtomicYamlConfigTransaction(target, async () => {
4528
+ await this.#pruneStrictInvalidEvidence(source, evidencePath, retainedEvidence);
4529
+ });
4530
+ } catch {
4531
+ // Transient source read failure: retain the evidence sidecar.
4532
+ }
4533
+ }
4534
+ this.#warnLegacyFallbackMigration(
4535
+ `Settings: project workflow migration recovered the rollback after a CAS conflict: restored migration-published keys whose values still matched`,
4536
+ );
4537
+ } catch (error) {
4538
+ this.#warnLegacyFallbackMigration(
4539
+ `Settings: project workflow migration could not recover the rollback for ${target}: ${error instanceof Error ? error.message : String(error)}; stale migration-published values may remain until the next repair`,
4540
+ );
4541
+ }
4542
+ }
4543
+
4544
+ /**
4545
+ * True when the config-root source still matches the bytes this migration
4546
+ * read (the hash is computed from the initial read). Used to gate evidence
4547
+ * retention: stale evidence written after an external repair/replacement
4548
+ * would make the direct command exit 2 for a now-valid source.
4549
+ */
4550
+ async #configRootSourceMatches(source: string, sourceSha256: string): Promise<boolean> {
4551
+ const current = await this.#sha256File(source).catch(() => null);
4552
+ return current === sourceSha256;
4553
+ }
4554
+
4555
+ /**
4556
+ * Config-root fallback-invalid marker path. The PROJECT marker stays next to
4557
+ * the legacy source (`${source}.fallback-invalid`, covered by .gitignore);
4558
+ * when the config-root source collides with a project source both migrations
4559
+ * would otherwise share that marker while writing fallbacks to DIFFERENT
4560
+ * targets, and one target's cleanup could delete the shared marker while the
4561
+ * other target's fallback value is still active. The config-root marker uses
4562
+ * a distinct name so ownership is per-target.
4563
+ */
4564
+ #configRootFallbackInvalidMarkerPath(source: string): string {
4565
+ return `${source}.config-root.fallback-invalid`;
4566
+ }
4567
+
4568
+ /**
4569
+ * Read the fallback-invalid marker (keys persisted into config.yml as
4570
+ * fallback evidence when the strict-evidence sidecar could not be written).
4571
+ * The marker lives next to the legacy source so it stays writable in the
4572
+ * exact scenario that produced it (.gjc/state/ is read-only or occupied).
4573
+ */
4574
+ async #readFallbackInvalidKeys(
4575
+ source: string,
4576
+ markerPath = `${source}.fallback-invalid`,
4577
+ ): Promise<StrictInvalidEvidenceEntry[]> {
4578
+ let raw: string;
4579
+ try {
4580
+ raw = await Bun.file(markerPath).text();
4581
+ } catch {
4582
+ return [];
4583
+ }
4584
+ try {
4585
+ const parsed = JSON.parse(raw) as unknown;
4586
+ if (!Array.isArray(parsed)) return [];
4587
+ const entries: StrictInvalidEvidenceEntry[] = [];
4588
+ for (const entry of parsed) {
4589
+ if (typeof entry === "string") {
4590
+ // v1 marker: key only (no recorded value) - never match an unset.
4591
+ if ((CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(entry)) {
4592
+ entries.push({ key: entry as WorkflowSettingKey, value: undefined });
4593
+ }
4594
+ } else if (entry && typeof entry === "object") {
4595
+ const { key, value } = entry as { key?: unknown; value?: unknown };
4596
+ if (
4597
+ typeof key === "string" &&
4598
+ (CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(key)
4599
+ ) {
4600
+ entries.push({ key: key as WorkflowSettingKey, value });
4601
+ }
4602
+ }
4603
+ }
4604
+ return entries;
4605
+ } catch {
4606
+ return [];
4607
+ }
4608
+ }
4609
+
4610
+ /**
4611
+ * Merge newly written fallback entries into the EXISTING fallback-invalid
4612
+ * marker. An evidence-unwritable source that first falls back key A and a
4613
+ * later load adds invalid key B (while A stays invalid) writes only B here -
4614
+ * A is already present in config.yml and excluded by the absent-target
4615
+ * filter. Replacing the marker with just B would lose A's tracking, so after
4616
+ * the source is repaired the cleanup could never remove A's fallback value
4617
+ * from config.yml (exit 2 persisting forever). New entries win over existing
4618
+ * ones for the same key.
4619
+ */
4620
+ async #mergeFallbackInvalidKeys(
4621
+ source: string,
4622
+ entries: readonly StrictInvalidEvidenceEntry[],
4623
+ markerPath = `${source}.fallback-invalid`,
4624
+ ): Promise<StrictInvalidEvidenceEntry[]> {
4625
+ const merged = new Map<WorkflowSettingKey, unknown>();
4626
+ for (const entry of await this.#readFallbackInvalidKeys(source, markerPath)) {
4627
+ merged.set(entry.key, entry.value);
4628
+ }
4629
+ for (const entry of entries) {
4630
+ merged.set(entry.key, entry.value);
4631
+ }
4632
+ return [...merged].map(([key, value]) => ({ key, value }));
4633
+ }
4634
+
4635
+ /** Atomically persist (or remove) the fallback-invalid marker. Returns false on failure. */
4636
+ async #writeFallbackInvalidKeys(
4637
+ source: string,
4638
+ entries: readonly StrictInvalidEvidenceEntry[],
4639
+ markerPath = `${source}.fallback-invalid`,
4640
+ ): Promise<boolean> {
4641
+ const tempPath = `${markerPath}.${nodeCrypto.randomUUID()}.tmp`;
4642
+ try {
4643
+ if (entries.length === 0) {
4644
+ await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
4645
+ return true;
4646
+ }
4647
+ // Publish atomically (stage + sync + rename): a direct in-place write of
4648
+ // a merged marker could be truncated by a crash, leaving malformed JSON
4649
+ // that #readFallbackInvalidKeys treats as empty while the recorded
4650
+ // config.yml values stay active and become un-cleanable.
4651
+ const tempHandle = await fs.promises.open(tempPath, "wx", 0o600);
4652
+ try {
4653
+ await tempHandle.writeFile(
4654
+ JSON.stringify(
4655
+ entries.map(entry => ({ key: entry.key, value: entry.value })),
4656
+ null,
4657
+ 2,
4658
+ ),
4659
+ "utf8",
4660
+ );
4661
+ await tempHandle.sync();
4662
+ } finally {
4663
+ await tempHandle.close();
4664
+ }
4665
+ await fs.promises.rename(tempPath, markerPath);
4666
+ // Durable publication: sync the marker's parent directory so the rename
4667
+ // (the marker's directory entry) survives a host crash even when the
4668
+ // temp content was already durable - otherwise a power loss can preserve
4669
+ // the fallback value in config.yml while the ownership marker is lost,
4670
+ // and the value becomes un-cleanable. Best-effort (mirrors the atomic
4671
+ // marker writer): platforms/filesystems that reject directory sync are
4672
+ // ignored.
4673
+ try {
4674
+ const dirHandle = await fs.promises.open(path.dirname(markerPath), "r");
4675
+ try {
4676
+ await dirHandle.sync();
4677
+ } finally {
4678
+ await dirHandle.close();
4679
+ }
4680
+ } catch {
4681
+ // Unsupported directory sync: ignore.
4682
+ }
4683
+ return true;
4684
+ } catch (error) {
4685
+ await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
4686
+ this.#warnLegacyFallbackMigration(
4687
+ `Settings: workflow migration could not write the fallback-invalid marker at ${markerPath}: ${error instanceof Error ? error.message : String(error)}`,
4688
+ );
4689
+ return false;
4690
+ }
4691
+ }
4692
+ /**
4693
+ * Fallback-invalid cleanup: values persisted into config.yml as fallback
4694
+ * evidence must be removed when the source no longer holds the invalid
4695
+ * value (the key was deleted or settings.json was removed), so exit 2 does
4696
+ * not persist after the user repairs the legacy source. A malformed source
4697
+ * keeps the fallback (the config.yml value stays the error surface).
4698
+ */
4699
+ async #clearStaleFallbackInvalidKeys(
4700
+ source: string,
4701
+ target: string,
4702
+ markerPath = `${source}.fallback-invalid`,
4703
+ ): Promise<void> {
4704
+ const entries = await this.#readFallbackInvalidKeys(source, markerPath);
4705
+ if (entries.length === 0) return;
4706
+
4707
+ const stillInvalid = new Set<WorkflowSettingKey>();
4708
+ let sourceText: string | null;
4709
+ try {
4710
+ sourceText = await Bun.file(source).text();
4711
+ } catch (error) {
4712
+ if (!isEnoent(error)) {
4713
+ // A transient read failure (permissions, I/O) must NOT be treated as
4714
+ // deletion: retain the fallback so the strict error stays observable.
4715
+ return;
4716
+ }
4717
+ // settings.json removed: nothing to keep the fallback alive.
4718
+ sourceText = null;
4719
+ }
4720
+ if (sourceText !== null) {
4721
+ let parsed: unknown;
4722
+ try {
4723
+ parsed = JSON.parse(sourceText);
4724
+ } catch {
4725
+ // Still malformed: keep the fallback as the observable error.
4726
+ return;
4727
+ }
4728
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
4729
+ for (const entry of entries) {
4730
+ const extracted = extractWorkflowSetting(parsed as Record<string, unknown>, entry.key);
4731
+ if (extracted.present && !this.#workflowKeyValueIsValid(entry.key, extracted.value)) {
4732
+ stillInvalid.add(entry.key);
4733
+ }
4734
+ }
4735
+ }
4736
+
4737
+ const toClear = entries.filter(entry => !stillInvalid.has(entry.key));
4738
+ if (toClear.length === 0) return;
4739
+ const toClearKeys = new Set(toClear.map(entry => entry.key));
4740
+ try {
4741
+ await withAtomicYamlConfigTransaction(target, async tx => {
4742
+ // A config.yml written by a NEWER schema version is read-only across
4743
+ // Settings; leave the target AND the fallback marker untouched, or
4744
+ // this cleanup (which runs before either migration's future-schema
4745
+ // guard) would rewrite configuration an older binary treats as
4746
+ // read-only.
4747
+ const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
4748
+ if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) {
4749
+ return;
4750
+ }
4751
+ // Unset only while the target still matches the migration's fallback
4752
+ // write: a user's newer hand-edited override (e.g. maxIterations: 9)
4753
+ // survives the cleanup.
4754
+ const unsets = toClear.filter(entry => {
4755
+ const current = extractWorkflowSetting(tx.root, entry.key, { flat: false });
4756
+ return (
4757
+ !current.malformedParent &&
4758
+ current.present &&
4759
+ JSON.stringify(current.value) === JSON.stringify(entry.value)
4760
+ );
4761
+ });
4762
+ if (unsets.length > 0) {
4763
+ await tx.applyPatches(unsets.map(entry => ({ path: entry.key, op: "unset" as const })));
4764
+ }
4765
+ // Reconcile the marker under the SAME lock the publication sites use:
4766
+ // another process may have merged a newly published fallback key
4767
+ // between this run's initial read and the transaction. Writing a
4768
+ // stale filtered snapshot here would drop that entry and leave its
4769
+ // config.yml value without ownership forever, so re-read the CURRENT
4770
+ // marker and remove only the keys THIS run cleared.
4771
+ await this.#writeFallbackInvalidKeys(
4772
+ source,
4773
+ (await this.#readFallbackInvalidKeys(source, markerPath)).filter(entry => !toClearKeys.has(entry.key)),
4774
+ markerPath,
4775
+ );
4776
+ });
4777
+ } catch (error) {
4778
+ this.#warnLegacyFallbackMigration(
4779
+ `Settings: workflow migration could not clear stale fallback-invalid values in ${target}: ${error instanceof Error ? error.message : String(error)}`,
4780
+ );
4781
+ return;
4782
+ }
4783
+ }
4784
+
4785
+ /**
4786
+ * Merge the per-key completion marker with the CURRENT on-disk marker (a
4787
+ * concurrent migration may have published its own between our initial read
4788
+ * and this transaction) and return the merged key list.
4789
+ */
4790
+ async #mergeProjectMigratedKeys(
4791
+ source: string,
4792
+ newlyOwned: readonly WorkflowSettingKey[],
4793
+ ): Promise<readonly WorkflowSettingKey[]> {
4794
+ if (newlyOwned.length === 0) return [];
4795
+ const currentMarker = await this.#readProjectMigratedKeys(source);
4796
+ return [...currentMarker, ...newlyOwned.filter(key => !currentMarker.has(key))];
4797
+ }
4798
+
4799
+ async #readBackupBytes(backup: string): Promise<{ bytes: ArrayBuffer; text: string }> {
4800
+ const bytes = await Bun.file(backup).arrayBuffer();
4801
+ return { bytes, text: Buffer.from(bytes).toString("utf8") };
4802
+ }
4803
+
4804
+ async #statIdentity(filePath: string): Promise<string | undefined> {
4805
+ const st = await fs.promises.stat(filePath).catch(() => null);
4806
+ return st ? `${st.dev}:${st.ino}` : undefined;
4807
+ }
4808
+
4809
+ async #targetFileIdentity(target: string): Promise<string | null> {
4810
+ const identity = await this.#statIdentity(target);
4811
+ return identity ?? null;
4812
+ }
4813
+
4814
+ async #workflowMigrationTargetIdentity(
4815
+ target: string,
4816
+ expected?: { canonicalTargetDir: string; canonicalTargetIdentity: string },
4817
+ ): Promise<{ canonicalTargetDir: string; canonicalTargetIdentity: string } | null> {
4818
+ const targetDir = path.dirname(target);
4819
+ const canonicalTargetDir = await fs.promises.realpath(targetDir).catch(() => null);
4820
+ if (canonicalTargetDir === null) return null;
4821
+ const canonicalTargetIdentity = await this.#statIdentity(targetDir);
4822
+ if (canonicalTargetIdentity === undefined) return null;
4823
+ if (
4824
+ expected &&
4825
+ (canonicalTargetDir !== expected.canonicalTargetDir ||
4826
+ canonicalTargetIdentity !== expected.canonicalTargetIdentity)
4827
+ ) {
4828
+ return null;
4829
+ }
4830
+ return { canonicalTargetDir, canonicalTargetIdentity };
4831
+ }
4832
+
4833
+ #isGlobalAgentScope(): boolean {
4834
+ // The Settings instance is bound to the environment-selected global agent
4835
+ // profile (GJC_CODING_AGENT_DIR / PI_CODING_AGENT_DIR or the default
4836
+ // <configRoot>/agent). An EXPLICITLY supplied temporary agentDir (SDK
4837
+ // loadForScope, tests) differs from getAgentDir() and must never consume
4838
+ // the machine-global config-root source; an environment-selected
4839
+ // non-default profile is a legitimate global profile and must migrate
4840
+ // like the default one.
4841
+ return path.resolve(this.#agentDir) === path.resolve(getAgentDir());
4842
+ }
4843
+
4844
+ #workflowMigrationTargetSatisfies(root: unknown, marker: WorkflowMigrationMarker): boolean {
4845
+ if (root === undefined || root === null) return marker.migratedKeys.length === 0;
4846
+ if (typeof root !== "object" || Array.isArray(root)) return false;
4847
+ const doc = root as Record<string, unknown>;
4848
+ return marker.migratedKeys.every(key => extractWorkflowSetting(doc, key, { flat: false }).present);
4849
+ }
4850
+ #workflowKeyValueIsValid(key: WorkflowSettingKey, value: unknown): boolean {
4851
+ const def = SETTINGS_SCHEMA[key] as
4852
+ | { type?: string; validate?: (value: number) => boolean; values?: readonly unknown[] }
4853
+ | undefined;
4854
+ if (!def) return true;
4855
+ let candidate: unknown = value;
4856
+ candidate = this.#coerceWorkflowScalar(key, candidate);
4857
+ switch (def.type) {
4858
+ case "enum":
4859
+ return def.values?.includes(candidate) ?? false;
4860
+ case "number":
4861
+ return def.validate !== undefined
4862
+ ? def.validate(candidate as number)
4863
+ : typeof candidate === "number" && Number.isFinite(candidate);
4864
+ case "boolean":
4865
+ return typeof candidate === "boolean";
4866
+ case "string":
4867
+ return typeof candidate === "string";
4868
+ default:
4869
+ return true;
4870
+ }
4871
+ }
4872
+ #workflowMigrationMarkerPathsMatch(
4873
+ marker: WorkflowMigrationMarker,
4874
+ source: string,
4875
+ backup: string,
4876
+ target: string,
4877
+ ): boolean {
4878
+ return (
4879
+ path.resolve(marker.sourcePath) === path.resolve(source) &&
4880
+ path.resolve(marker.backupPath) === path.resolve(backup) &&
4881
+ path.resolve(marker.targetPath) === path.resolve(target)
4882
+ );
4883
+ }
4884
+ /**
4885
+ * Mirror the resolver/Settings scalar coercion for a workflow key: a quoted
4886
+ * numeric string for a number setting (e.g. `maxIterations: "9"`) becomes
4887
+ * the number 9. Used both for validity checks and for what the migration
4888
+ * persists into config.yml.
4889
+ */
4890
+ #coerceWorkflowScalar(key: WorkflowSettingKey, value: unknown): unknown {
4891
+ const def = SETTINGS_SCHEMA[key] as { type?: string } | undefined;
4892
+ if (
4893
+ def?.type === "number" &&
4894
+ typeof value === "string" &&
4895
+ value.trim() !== "" &&
4896
+ Number.isFinite(Number(value))
4897
+ ) {
4898
+ return Number(value);
4899
+ }
4900
+ return value;
4901
+ }
4902
+
4903
+ async #readWorkflowMigrationMarker(markerPath: string): Promise<WorkflowMigrationMarker | null> {
4904
+ let raw: string;
4905
+ try {
4906
+ raw = await Bun.file(markerPath).text();
4907
+ } catch (error) {
4908
+ // Only ENOENT means no marker; a transient EACCES/EIO read failure
4909
+ // must propagate so a valid pending marker is never quarantined as
4910
+ // corrupt and its ownership evidence lost.
4911
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
4912
+ throw error;
4913
+ }
4914
+ try {
4915
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
4916
+ if (parsed.version !== WORKFLOW_MIGRATION_MARKER_VERSION) return null;
4917
+ if (parsed.status !== "pending" && parsed.status !== "complete") return null;
4918
+ if (
4919
+ typeof parsed.sourcePath !== "string" ||
4920
+ typeof parsed.backupPath !== "string" ||
4921
+ typeof parsed.targetPath !== "string" ||
4922
+ typeof parsed.sourceSha256 !== "string" ||
4923
+ !/^[0-9a-f]{64}$/.test(parsed.sourceSha256) ||
4924
+ typeof parsed.startedAt !== "string" ||
4925
+ Number.isNaN(Date.parse(parsed.startedAt)) ||
4926
+ !Array.isArray(parsed.migratedKeys) ||
4927
+ !parsed.migratedKeys.every(
4928
+ key =>
4929
+ typeof key === "string" && (CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(key),
4930
+ )
4931
+ ) {
4932
+ return null;
4933
+ }
4934
+ if (
4935
+ parsed.status === "complete" &&
4936
+ (typeof parsed.completedAt !== "string" || Number.isNaN(Date.parse(parsed.completedAt)))
4937
+ ) {
4938
+ return null;
4939
+ }
4940
+ return parsed as WorkflowMigrationMarker;
4941
+ } catch {
4942
+ return null;
4943
+ }
4944
+ }
4945
+
4946
+ async #writeWorkflowMigrationMarkerAtomic(markerPath: string, marker: WorkflowMigrationMarker): Promise<void> {
4947
+ if (marker.status === "complete") {
4948
+ if (typeof marker.canonicalTargetIdentity !== "string" || marker.canonicalTargetIdentity.length === 0) {
4949
+ throw new Error("Cannot publish a complete workflow migration marker without target directory identity.");
4950
+ }
4951
+ const currentTargetIdentity = await this.#statIdentity(path.dirname(marker.targetPath));
4952
+ if (currentTargetIdentity !== marker.canonicalTargetIdentity) {
4953
+ throw new Error("Cannot publish a complete workflow migration marker without a matching target directory.");
4954
+ }
4955
+ }
4956
+ const serialized = JSON.stringify(marker, null, 2);
4957
+ const directory = path.dirname(markerPath);
4958
+ const tempPath = path.join(
4959
+ directory,
4960
+ `.${path.basename(markerPath)}.${process.pid}.${nodeCrypto.randomUUID()}.tmp`,
4961
+ );
4962
+ try {
4963
+ await Bun.write(tempPath, serialized);
4964
+ // Durable before publication: fsync the marker file so a crash
4965
+ // cannot leave a rename that survives while the marker content is
4966
+ // lost.
4967
+ const tempHandle = await fs.promises.open(tempPath, "r");
4968
+ try {
4969
+ await tempHandle.sync();
4970
+ } finally {
4971
+ await tempHandle.close();
4972
+ }
4973
+ await fs.promises.rename(tempPath, markerPath);
4974
+ // Durable publication: sync the parent directory so the rename (the
4975
+ // marker's directory entry) survives a host crash even when the
4976
+ // temp file content was already durable.
4977
+ // Best-effort (like the atomic-YAML writer): some platforms (Windows,
4978
+ // filesystems that reject opening/syncing directories) throw here -
4979
+ // the marker rename is already durable; a directory-sync failure
4980
+ // must not abort the migration.
4981
+ try {
4982
+ const dirHandle = await fs.promises.open(directory, "r");
4983
+ try {
4984
+ await dirHandle.sync();
4985
+ } finally {
4986
+ await dirHandle.close();
4987
+ }
4988
+ } catch {
4989
+ // Unsupported directory sync: ignore.
4990
+ }
4991
+ } finally {
4992
+ await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
4993
+ }
4994
+ }
4995
+
4996
+ /**
4997
+ * True when the config-root source is the SAME physical file as the current
4998
+ * project's `.gjc/settings.json` (GJC run from the config root itself, e.g.
4999
+ * the user's home with the default `.gjc`): path identity first, then inode
5000
+ * identity for symlink/hardlink aliases.
5001
+ */
5002
+ async #configRootCollidesWithProjectSource(source: string): Promise<boolean> {
5003
+ const projectSource = path.resolve(this.#cwd, ".gjc", "settings.json");
5004
+ if (path.resolve(source) === projectSource) return true;
5005
+ const [sourceStat, projectStat] = await Promise.all([
5006
+ fs.promises.stat(source).catch(() => null),
5007
+ fs.promises.stat(projectSource).catch(() => null),
5008
+ ]);
5009
+ return !!sourceStat && !!projectStat && sourceStat.dev === projectStat.dev && sourceStat.ino === projectStat.ino;
5010
+ }
5011
+
5012
+ /**
5013
+ * Reconcile the config-root sidecars (strict evidence prune + fallback-invalid
5014
+ * cleanup) against the AGENT target. Shared by the normal migration start and
5015
+ * the project-source collision deferral: in the collision, the config-root
5016
+ * fallback values live in the agent config.yml and the strict evidence sits
5017
+ * at the agent layer slot, so they must be reconciled here - the project
5018
+ * migration only cleans against the project config.yml and project evidence,
5019
+ * and would otherwise delete the shared fallback marker without removing the
5020
+ * agent config.yml value, orphaning it (strict ralplan failing forever).
5021
+ */
5022
+ async #reconcileConfigRootSidecars(source: string, target: string): Promise<void> {
5023
+ const evidencePath = `${source}.strict-invalid`;
5024
+ const retainedEvidence = await this.#readStrictInvalidEvidence(evidencePath);
5025
+ if (retainedEvidence) {
5026
+ try {
5027
+ // Prune to the still-invalid keys: repairing ONE recorded key must
5028
+ // not drop the evidence for the remaining unresolved keys. Runs
5029
+ // under the target lock so a concurrent process's evidence
5030
+ // publication serializes with this prune.
5031
+ await withAtomicYamlConfigTransaction(target, async () => {
5032
+ await this.#pruneStrictInvalidEvidence(source, evidencePath, retainedEvidence);
5033
+ });
5034
+ } catch {
5035
+ // Transient source read failure: retain the evidence sidecar.
5036
+ }
5037
+ }
5038
+
5039
+ // Fallback-invalid cleanup: config.yml values persisted as fallback
5040
+ // evidence are removed when the source no longer holds the invalid value
5041
+ // (key deleted or settings.json removed), so exit 2 does not persist. The
5042
+ // config-root marker namespace is distinct from the project's so a
5043
+ // colliding project source never shares ownership state across targets.
5044
+ await this.#clearStaleFallbackInvalidKeys(source, target, this.#configRootFallbackInvalidMarkerPath(source));
5045
+ }
5046
+
5047
+ /**
5048
+ * Retire the config-root legacy source once migration is complete (shared by
5049
+ * the completion path and the completed-marker crash recovery). Only the
5050
+ * EXACT revision whose bytes match `expectedSourceSha256` is removed: the
5051
+ * source is first RENAMED to a unique quarantine path (an atomic capture of
5052
+ * whatever the path currently holds); the quarantine is unlinked only when
5053
+ * its bytes still match. A quarantined concurrent replacement is restored to
5054
+ * `source` (or left in quarantine if a new file already occupies the path) -
5055
+ * never deleted by pathname.
5056
+ */
5057
+ async #retireConfigRootSource(source: string, expectedSourceSha256: string): Promise<void> {
5058
+ const quarantinePath = `${source}.migrated-src-${nodeCrypto.randomUUID()}`;
5059
+ let quarantined = false;
5060
+ try {
5061
+ await fs.promises.rename(source, quarantinePath);
5062
+ quarantined = true;
5063
+ } catch {
5064
+ // Source already gone (or locked); nothing to retire.
5065
+ }
5066
+ if (!quarantined) return;
5067
+ const quarantinedHash = await this.#sha256File(quarantinePath).catch(() => null);
5068
+ if (quarantinedHash === expectedSourceSha256) {
5069
+ await fs.promises.rm(quarantinePath, { force: true }).catch(() => undefined);
5070
+ return;
5071
+ }
5072
+ // A concurrent replacement was quarantined; restore it WITHOUT replacement
5073
+ // semantics: hard-link back only when the original path is free (link
5074
+ // fails with EEXIST when an editor has since published a new file there),
5075
+ // and leave the quarantined object intact in that case - never overwrite
5076
+ // or delete the editor's latest revision.
5077
+ try {
5078
+ await fs.promises.link(quarantinePath, source);
5079
+ await fs.promises.rm(quarantinePath, { force: true }).catch(() => undefined);
5080
+ } catch {
5081
+ // `source` is occupied (EEXIST) or linking failed; keep the
5082
+ // quarantined object under its unique name.
5083
+ }
5084
+ }
5085
+
5086
+ async #moveLegacySourceNoReplace(
5087
+ source: string,
5088
+ destination: string,
5089
+ expectedSourceSha256?: string,
5090
+ ): Promise<boolean> {
5091
+ try {
5092
+ await fs.promises.lstat(destination);
5093
+ return false; // Never overwrite an existing destination.
5094
+ } catch (error) {
5095
+ if (!isEnoent(error)) return false;
5096
+ }
5097
+ if (expectedSourceSha256 !== undefined) {
5098
+ // USER-DATA move: use an INDEPENDENT copy (never a hard link - a kept
5099
+ // source and a hard-linked backup share an inode, so a later in-place
5100
+ // edit or truncation of the still-active legacy file would mutate the
5101
+ // backup and the marker hash would no longer preserve the migrated
5102
+ // bytes) and keep the source ACTIVE (never unlink; a path-based unlink
5103
+ // after a non-atomic identity check could delete a rename-replaced
5104
+ // file). The caller re-verifies the source before the complete marker.
5105
+ try {
5106
+ await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
5107
+ } catch {
5108
+ return false;
5109
+ }
5110
+ // Durable before the complete marker: sync the copied backup (a power
5111
+ // loss must not leave a surviving marker + target with a missing or
5112
+ // empty backup).
5113
+ const userCopyHandle = await fs.promises.open(destination, "r");
5114
+ try {
5115
+ await userCopyHandle.sync();
5116
+ } finally {
5117
+ await userCopyHandle.close();
5118
+ }
5119
+ // Capture the identity of the copy we just made BEFORE any further
5120
+ // verification: an external replacement or in-place edit after this
5121
+ // point must never be removed by an abort path.
5122
+ let copiedDestIno: number | null = null;
5123
+ let copiedDestSha: string | null = null;
5124
+ try {
5125
+ copiedDestIno = (await fs.promises.stat(destination)).ino;
5126
+ copiedDestSha = await this.#sha256File(destination);
5127
+ } catch {
5128
+ // The copy vanished right after creation; nothing of ours remains.
5129
+ return false;
5130
+ }
5131
+ await SettingsMigrationTestHooks.afterBackupIdentityCaptured?.(destination);
5132
+ let copiedSourceHash: string | null = null;
5133
+ try {
5134
+ copiedSourceHash = await this.#sha256File(source);
5135
+ } catch {
5136
+ // The source was deleted right after the copy: remove the copy
5137
+ // ONLY while it is still the file we created (an external
5138
+ // replacement or edit at the backup pathname must be preserved)
5139
+ // and report failure so the caller reverts the target.
5140
+ await this.#removeIfStillOurs(destination, copiedDestIno, copiedDestSha);
5141
+ return false;
5142
+ }
5143
+ if (copiedSourceHash !== expectedSourceSha256) {
5144
+ await this.#removeIfStillOurs(destination, copiedDestIno, copiedDestSha);
5145
+ return false;
5146
+ }
5147
+ return true;
5148
+ }
5149
+ // Internal artifacts (marker quarantine): capture the inode, hard-link
5150
+ // (copy fallback), and remove the source name only while it is still the
5151
+ // inode we verified.
5152
+ let sourceIno: number | undefined;
5153
+ try {
5154
+ sourceIno = (await fs.promises.stat(source)).ino;
5155
+ } catch {
5156
+ return false;
5157
+ }
5158
+ try {
5159
+ // Atomic same-directory no-clobber move via hard link + unlink.
5160
+ await fs.promises.link(source, destination);
5161
+ } catch (error) {
5162
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
5163
+ // Filesystems without hard links: a no-clobber copy. COPYFILE_EXCL
5164
+ // fails with EEXIST if the destination appears, so it can never
5165
+ // replace an existing `.bak`/quarantine - unlike a raw rename, which
5166
+ // would overwrite a destination created after the lstat above.
5167
+ try {
5168
+ await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
5169
+ } catch {
5170
+ return false;
5171
+ }
5172
+ // Durable before the complete marker: sync the copied backup file (a
5173
+ // power loss must not leave a surviving marker + target with a
5174
+ // missing or empty backup).
5175
+ const copyHandle = await fs.promises.open(destination, "r");
5176
+ try {
5177
+ await copyHandle.sync();
5178
+ } finally {
5179
+ await copyHandle.close();
5180
+ }
5181
+ }
5182
+ let destinationIno: number | null = null;
5183
+ try {
5184
+ destinationIno = (await fs.promises.stat(destination)).ino;
5185
+ } catch {
5186
+ return false;
5187
+ }
5188
+ if (!(await this.#legacySourceStillVerified(source, sourceIno))) {
5189
+ // Remove the destination ONLY while it is still the entry created
5190
+ // above; an external replacement at the quarantine pathname must be
5191
+ // preserved.
5192
+ await this.#removeIfStillOurs(destination, destinationIno);
5193
+ return false;
5194
+ }
5195
+ try {
5196
+ await fs.promises.rm(source, { force: true });
5197
+ } catch {
5198
+ return false;
5199
+ }
5200
+ return true;
5201
+ }
5202
+
5203
+ /**
5204
+ * Remove `path` only while it is still the file this migration created:
5205
+ * the observed inode must match and (when a hash is given) the bytes must
5206
+ * too. A concurrent external process that replaced or edited the file at
5207
+ * this pathname must never be deleted - the migration's no-clobber promise
5208
+ * preserves externally created `.bak`/quarantine data, mirroring the
5209
+ * identity guards already applied to config targets and strict-evidence
5210
+ * files.
5211
+ */
5212
+ async #removeIfStillOurs(path: string, expectedIno: number, expectedSha256?: string): Promise<boolean> {
5213
+ // Atomically QUARANTINE the entry (same-directory rename) before any
5214
+ // verification: the identity check and the unlink then operate on the
5215
+ // private quarantine name, so an external process that replaces the
5216
+ // file at the PUBLIC pathname after the check but before the removal
5217
+ // can no longer have its file deleted. Whatever the rename moved is
5218
+ // what gets verified; a mismatch is restored to the public pathname
5219
+ // (no-clobber) or retained under the quarantine name, never unlinked.
5220
+ const quarantine = `${path}.quarantine-${process.pid}-${nodeCrypto.randomUUID()}`;
5221
+ try {
5222
+ await fs.promises.rename(path, quarantine);
5223
+ } catch {
5224
+ // Nothing to remove (ENOENT) or the entry cannot be moved: removal
5225
+ // by pathname is never attempted - the quarantine is the only
5226
+ // removal basis.
5227
+ return false;
5228
+ }
5229
+ const stat = await fs.promises.stat(quarantine).catch(() => null);
5230
+ if (!stat || stat.ino !== expectedIno) {
5231
+ await this.#restoreQuarantined(quarantine, path);
5232
+ return false;
5233
+ }
5234
+ if (expectedSha256 !== undefined && (await this.#sha256File(quarantine)) !== expectedSha256) {
5235
+ await this.#restoreQuarantined(quarantine, path);
5236
+ return false;
5237
+ }
5238
+ await SettingsMigrationTestHooks.beforeQuarantineRemoval?.(path);
5239
+ await fs.promises.rm(quarantine, { force: true }).catch(() => undefined);
5240
+ return true;
5241
+ }
5242
+
5243
+ /**
5244
+ * Restore a quarantined entry to its original pathname without ever
5245
+ * replacing a file another process published there meanwhile: the restore
5246
+ * is no-clobber (hard link, then a copy fallback), and on any failure the
5247
+ * quarantined entry is RETAINED (never deleted) so the data stays
5248
+ * recoverable.
5249
+ */
5250
+ async #restoreQuarantined(quarantine: string, original: string): Promise<void> {
5251
+ try {
5252
+ await fs.promises.link(quarantine, original);
5253
+ await fs.promises.rm(quarantine, { force: true }).catch(() => undefined);
5254
+ return;
5255
+ } catch (error) {
5256
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
5257
+ // The pathname was re-occupied while quarantined: keep the
5258
+ // quarantined entry (displaced, never deleted).
5259
+ return;
5260
+ }
5261
+ }
5262
+ try {
5263
+ // Filesystems without hard links: a no-clobber copy fallback.
5264
+ await fs.promises.copyFile(quarantine, original, fs.constants.COPYFILE_EXCL);
5265
+ await fs.promises.rm(quarantine, { force: true }).catch(() => undefined);
5266
+ } catch {
5267
+ // Occupied or uncopyable: retain the quarantined file so the data
5268
+ // stays recoverable.
5269
+ }
5270
+ }
5271
+ /**
5272
+ * True only if `path` still refers to the same inode that was verified
5273
+ * earlier and (when an expected hash is given) still holds the verified
5274
+ * bytes. Used immediately before any unlink of a legacy source so a
5275
+ * concurrent rename-style save or in-place edit is never consumed.
5276
+ */
5277
+ async #legacySourceStillVerified(
5278
+ path: string,
5279
+ expectedIno: number,
5280
+ expectedSourceSha256?: string,
5281
+ ): Promise<boolean> {
5282
+ const stat = await fs.promises.stat(path).catch(() => null);
5283
+ if (!stat || stat.ino !== expectedIno) return false;
5284
+ if (expectedSourceSha256 !== undefined && (await this.#sha256File(path)) !== expectedSourceSha256) return false;
5285
+ return true;
5286
+ }
5287
+
5288
+ async #pathExists(target: string): Promise<boolean> {
5289
+ try {
5290
+ await fs.promises.lstat(target);
5291
+ return true;
5292
+ } catch (error) {
5293
+ // Only ENOENT means absence; a transient EACCES/EIO failure must
5294
+ // propagate so recovery never mistakes it for a deletion/removal.
5295
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
5296
+ throw error;
5297
+ }
5298
+ }
5299
+
5300
+ async #sha256File(target: string): Promise<string> {
5301
+ const raw = await Bun.file(target).arrayBuffer();
5302
+ return nodeCrypto.createHash("sha256").update(Buffer.from(raw)).digest("hex");
5303
+ }
5304
+
5305
+ #hasCustomThemeFile(name: string): boolean {
5306
+ try {
5307
+ return fs.existsSync(path.join(getCustomThemesDir(this.#agentDir), `${name}.json`));
5308
+ } catch {
5309
+ return false;
5310
+ }
5311
+ }
5312
+
5313
+ #migrateLegacyBuiltInThemeName(name: string): string {
5314
+ if (isLegacyThemeName(name) && !this.#hasCustomThemeFile(name)) {
5315
+ return LEGACY_THEME_NAME_REPLACEMENTS[name];
5316
+ }
5317
+ return name;
5318
+ }
5319
+
5320
+ #getThemeSlotForName(name: string): "dark" | "light" {
5321
+ return isLightTheme(name, this.#agentDir) ? "light" : "dark";
5322
+ }
5323
+
5324
+ /** Apply registered schema migrations once, using configSchemaVersion as the durable marker. */
5325
+ #migrateRawSettings(raw: RawSettings): RawSettings {
5326
+ const configuredVersion = raw.configSchemaVersion;
5327
+ if (configuredVersion === CONFIG_SCHEMA_VERSION) return raw;
5328
+ if (typeof configuredVersion === "number" && configuredVersion > CONFIG_SCHEMA_VERSION) return raw;
5329
+
5330
+ // Migration registry v0 -> v1.
5331
+ // queueMode -> steeringMode
5332
+ normalizeSessionDirectoryMigration(raw);
5333
+ if ("queueMode" in raw && !("steeringMode" in raw)) {
5334
+ raw.steeringMode = raw.queueMode;
5335
+ delete raw.queueMode;
5336
+ }
5337
+ // ask.timeout: v0 stored milliseconds; v1 stores seconds.
5338
+ if (raw.ask && typeof (raw.ask as Record<string, unknown>).timeout === "number") {
5339
+ const oldValue = (raw.ask as Record<string, unknown>).timeout as number;
5340
+ if (oldValue > 1000) (raw.ask as Record<string, unknown>).timeout = Math.round(oldValue / 1000);
5341
+ }
5342
+
5343
+ // Migrate old flat "theme" string to nested theme.dark/theme.light
5344
+ if (typeof raw.theme === "string") {
5345
+ const oldTheme = raw.theme;
5346
+ const migratedTheme = this.#migrateLegacyBuiltInThemeName(oldTheme);
5347
+ if (oldTheme === "dark" && migratedTheme === "red-claw") {
5348
+ raw.theme = { dark: migratedTheme };
5349
+ } else if (oldTheme === "light" && migratedTheme === "blue-crab") {
5350
+ raw.theme = { light: migratedTheme };
5351
+ } else {
5352
+ const slot = this.#getThemeSlotForName(migratedTheme);
5353
+ raw.theme = { [slot]: migratedTheme };
5354
+ }
5355
+ } else if (raw.theme && typeof raw.theme === "object" && !Array.isArray(raw.theme)) {
5356
+ const themeObj = raw.theme as Record<string, unknown>;
5357
+ if (typeof themeObj.dark === "string") {
5358
+ themeObj.dark = this.#migrateLegacyBuiltInThemeName(themeObj.dark);
5359
+ }
5360
+ if (typeof themeObj.light === "string") {
5361
+ themeObj.light = this.#migrateLegacyBuiltInThemeName(themeObj.light);
5362
+ }
5363
+ }
5364
+
5365
+ // task.isolation.enabled (boolean) -> task.isolation.mode (enum)
5366
+ const taskObj = raw.task as Record<string, unknown> | undefined;
5367
+ const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
5368
+ if (isolationObj && "enabled" in isolationObj) {
5369
+ if (typeof isolationObj.enabled === "boolean") {
5370
+ isolationObj.mode = isolationObj.enabled ? "auto" : "none";
5371
+ }
5372
+ delete isolationObj.enabled;
5373
+ }
5374
+
5375
+ // task.isolation.mode: legacy values from before the pi-iso PAL refactor.
5376
+ // `worktree` was git worktree → now lives under `rcopy`. `fuse-overlay`
5377
+ // and `fuse-projfs` are now the platform-named `overlayfs` / `projfs`
5378
+ // kinds; the PAL falls back internally when the chosen one isn't
5379
+ // available, so we don't need the old TS-side platform guards.
5380
+ if (isolationObj && typeof isolationObj.mode === "string") {
5381
+ const legacy: Record<string, string> = {
5382
+ worktree: "rcopy",
5383
+ "fuse-overlay": "overlayfs",
5384
+ "fuse-projfs": "projfs",
5385
+ };
5386
+ const mapped = legacy[isolationObj.mode as string];
5387
+ if (mapped !== undefined) {
5388
+ isolationObj.mode = mapped;
5389
+ }
5390
+ }
5391
+
5392
+ // edit.mode: removed "atom" variant is now "hashline"
5393
+ const editObj = raw.edit as Record<string, unknown> | undefined;
5394
+ if (editObj) {
5395
+ if (editObj.mode === "atom") {
5396
+ editObj.mode = "hashline";
5397
+ }
5398
+ const modelVariants = editObj.modelVariants as Record<string, unknown> | undefined;
5399
+ if (modelVariants && typeof modelVariants === "object" && !Array.isArray(modelVariants)) {
5400
+ for (const [pattern, variant] of Object.entries(modelVariants)) {
5401
+ if (variant === "atom") {
5402
+ modelVariants[pattern] = "hashline";
5403
+ }
5404
+ }
5405
+ }
5406
+ }
5407
+ if (raw["edit.mode"] === "atom") {
5408
+ raw["edit.mode"] = "hashline";
5409
+ }
5410
+
5411
+ // statusLine: rename "plan_mode" segment to "mode"
5412
+ const statusLineObj = raw.statusLine as Record<string, unknown> | undefined;
5413
+ if (statusLineObj) {
5414
+ for (const key of ["leftSegments", "rightSegments"] as const) {
5415
+ const segments = statusLineObj[key];
5416
+ if (Array.isArray(segments)) {
5417
+ statusLineObj[key] = segments.map(seg => (seg === "plan_mode" ? "mode" : seg));
5418
+ }
5419
+ }
5420
+ const segmentOptions = statusLineObj.segmentOptions as Record<string, unknown> | undefined;
5421
+ if (segmentOptions && "plan_mode" in segmentOptions && !("mode" in segmentOptions)) {
5422
+ segmentOptions.mode = segmentOptions.plan_mode;
5423
+ delete segmentOptions.plan_mode;
5424
+ }
5425
+ }
5426
+
5427
+ // Map legacy `memories.enabled` boolean to the explicit `memory.backend`
5428
+ // enum if the latter hasn't been set yet. Idempotent: subsequent
5429
+ // migrations are no-ops once memory.backend is materialised.
5430
+ const memoryBackendObj = raw.memory as Record<string, unknown> | undefined;
5431
+ const memoryBackendSet = memoryBackendObj && typeof memoryBackendObj.backend === "string";
5432
+ const memoriesObj = raw.memories as Record<string, unknown> | undefined;
5433
+ if (!memoryBackendSet && memoriesObj && typeof memoriesObj.enabled === "boolean") {
5434
+ const next = memoriesObj.enabled ? "local" : "off";
5435
+ const memoryRoot = (memoryBackendObj ?? {}) as Record<string, unknown>;
5436
+ memoryRoot.backend = next;
5437
+ raw.memory = memoryRoot;
5438
+ }
5439
+
1635
5440
  // hindsight: dynamicBankId/agentName -> scoping enum + bankId
1636
5441
  // - dynamicBankId=true → scoping="per-project" (closest semantic match;
1637
5442
  // the legacy `agent::project::channel::user` tuple was per-project in
@@ -1664,35 +5469,28 @@ export class Settings implements NotificationSettingsReader {
1664
5469
  // Migrate legacy providers.image* settings into modelRoles.image.
1665
5470
  // The image-generation model is now selected via the model-role system
1666
5471
  // (/model image <selector>) instead of a separate provider config dialog.
1667
- const providersObj = raw.providers as Record<string, unknown> | undefined;
5472
+ const providersObj = rawSettingsRecord(raw.providers);
1668
5473
  if (providersObj && typeof providersObj.image === "string") {
1669
- const imageProvider = providersObj.image as string;
5474
+ const imageProvider = providersObj.image;
1670
5475
  const imageModel = typeof providersObj.imageModel === "string" ? providersObj.imageModel : undefined;
1671
- const imageCustomUrl =
1672
- typeof providersObj.imageCustomUrl === "string" ? providersObj.imageCustomUrl : undefined;
1673
-
1674
- if (imageProvider !== "auto") {
1675
- const roles = rawSettingsRecord(raw.modelRoles) ?? {};
1676
- if (!roles.image) {
1677
- if (imageProvider === "custom" && imageCustomUrl) {
1678
- // Custom providers are registered as first-class OpenAI-compatible providers;
1679
- // the migration produces a provider/model selector.
1680
- const customProviderId = "image-custom";
1681
- roles.image = `${customProviderId}/${imageModel ?? "gpt-image-2"}`;
1682
- } else if (imageModel) {
1683
- roles.image = `${imageProvider}/${imageModel}`;
1684
- } else {
1685
- roles.image = imageProvider;
5476
+
5477
+ // A custom endpoint belongs in models.yml. Retain the complete legacy
5478
+ // configuration rather than creating an unresolvable image-custom selector.
5479
+ if (imageProvider !== "custom") {
5480
+ if (imageProvider !== "auto") {
5481
+ const roles = rawSettingsRecord(raw.modelRoles) ?? {};
5482
+ if (!roles.image) {
5483
+ roles.image = imageModel ? `${imageProvider}/${imageModel}` : imageProvider;
5484
+ raw.modelRoles = roles;
1686
5485
  }
1687
- raw.modelRoles = roles;
1688
5486
  }
1689
- }
1690
5487
 
1691
- delete providersObj.image;
1692
- delete providersObj.imageModel;
1693
- delete providersObj.imageCustomUrl;
1694
- delete providersObj.imageCustomKey;
1695
- delete providersObj.imageCustomKeyEnv;
5488
+ delete providersObj.image;
5489
+ delete providersObj.imageModel;
5490
+ delete providersObj.imageCustomUrl;
5491
+ delete providersObj.imageCustomKey;
5492
+ delete providersObj.imageCustomKeyEnv;
5493
+ }
1696
5494
  }
1697
5495
 
1698
5496
  raw.configSchemaVersion = CONFIG_SCHEMA_VERSION;
@@ -2076,10 +5874,29 @@ export class Settings implements NotificationSettingsReader {
2076
5874
  #stripProjectNotificationSettings(settings: RawSettings): {
2077
5875
  settings: RawSettings;
2078
5876
  rejectedNotifications: boolean;
5877
+ rejectedCredentialPins: boolean;
2079
5878
  } {
2080
5879
  let rejectedNotifications = false;
5880
+ let rejectedCredentialPins = false;
2081
5881
  const sanitized: RawSettings = {};
2082
5882
  for (const [key, value] of Object.entries(settings)) {
5883
+ if (key === "auth" && value && typeof value === "object" && !Array.isArray(value)) {
5884
+ const authSettings = { ...(value as Record<string, unknown>) };
5885
+ if (Object.hasOwn(authSettings, "credentialPins")) {
5886
+ delete authSettings.credentialPins;
5887
+ rejectedCredentialPins = true;
5888
+ }
5889
+ if (Object.hasOwn(authSettings, "credentialPinStoreIdentity")) {
5890
+ delete authSettings.credentialPinStoreIdentity;
5891
+ rejectedCredentialPins = true;
5892
+ }
5893
+ if (Object.keys(authSettings).length > 0) sanitized[key] = authSettings;
5894
+ continue;
5895
+ }
5896
+ if (key === "auth.credentialPins" || key === "auth.credentialPinStoreIdentity") {
5897
+ rejectedCredentialPins = true;
5898
+ continue;
5899
+ }
2083
5900
  if (key === "notifications" && value && typeof value === "object" && !Array.isArray(value)) {
2084
5901
  const localNotifications: Record<string, unknown> = {};
2085
5902
  for (const [notificationKey, notificationValue] of Object.entries(value)) {
@@ -2098,7 +5915,7 @@ export class Settings implements NotificationSettingsReader {
2098
5915
  }
2099
5916
  sanitized[key] = value;
2100
5917
  }
2101
- return { settings: sanitized, rejectedNotifications };
5918
+ return { settings: sanitized, rejectedNotifications, rejectedCredentialPins };
2102
5919
  }
2103
5920
 
2104
5921
  #deepMerge(base: RawSettings, overrides: RawSettings): RawSettings {
@@ -2204,6 +6021,28 @@ export function resetSettingsForTest(): void {
2204
6021
  globalInitOptions = null;
2205
6022
  }
2206
6023
 
6024
+ /**
6025
+ * Ensure the one-time legacy workflow settings migrations have run for `cwd`
6026
+ * before a workflow runtime resolves settings. The direct CLI commands
6027
+ * (`gjc ralplan`, `gjc deep-interview`, `gjc ultragoal`) never initialize
6028
+ * Settings, so without this the project `.gjc/settings.json` and config-root
6029
+ * `settings.json` workflow keys would silently fall back to defaults after the
6030
+ * resolver stopped reading settings.json. No-op when the global Settings
6031
+ * singleton is already initialized (session path). Best-effort: a settings
6032
+ * load failure must not block the native command, whose resolver falls back to
6033
+ * defaults exactly as before.
6034
+ */
6035
+ export async function ensureWorkflowSettingsMigrated(cwd: string): Promise<void> {
6036
+ if (isSettingsInitialized()) return;
6037
+ try {
6038
+ await Settings.loadForScope({ cwd });
6039
+ } catch (error) {
6040
+ logger.warn("Settings: workflow migration trigger could not load settings", {
6041
+ error: error instanceof Error ? error.message : String(error),
6042
+ });
6043
+ }
6044
+ }
6045
+
2207
6046
  /**
2208
6047
  * The global settings singleton.
2209
6048
  * Must call `Settings.init()` before using.