@gajae-code/coding-agent 0.16.7 → 0.17.1

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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -0,0 +1,1157 @@
1
+ import * as fsp from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { Settings } from "../../config/settings";
6
+ import { GJC_BUNDLE_KIND, type ReviewedRestoreTokenV1 } from "../../extensibility/gjc-plugins/types";
7
+ import {
8
+ type MarketplaceManager,
9
+ type MarketplaceRestoreApplyResult,
10
+ MarketplaceRestoreError,
11
+ type MarketplaceReviewedRestoreTokenV1,
12
+ } from "../../extensibility/plugins/marketplace/manager";
13
+ import { restartBrokerForDoctor } from "../../sdk/broker/doctor-restart";
14
+ import { restartDaemonForDoctor } from "../../sdk/bus/doctor-daemon-restart-client";
15
+ import { type DoctorAction, type DoctorConfirmationResult, isDoctorAction } from "./args";
16
+ import type { DoctorConfigRepairRequest, DoctorConfigRepairResult } from "./config-repairs";
17
+ import type { DoctorContext, DoctorPluginTarget } from "./context";
18
+ import { repairStandaloneBinary } from "./install-repairs";
19
+ import { repairManagedLink } from "./managed-link";
20
+ import { applyPluginQuarantine } from "./plugin-quarantine";
21
+ import { runPluginRestoreAction } from "./plugin-restore";
22
+ import { createDoctorMarketplaceManager } from "./plugin-targets";
23
+ import { parseServiceRecord, readDoctorService, serviceArtifactObservation } from "./service-targets";
24
+ import type { DoctorCheck, DoctorRepair, DoctorRiskClass, ReadinessFactCode, RepairCandidate } from "./types";
25
+
26
+ const loadConfigRepair = () => import("./config-repairs");
27
+ const loadPermissionRepair = () => import("./permission-repairs");
28
+ const loadServiceRepair = () => import("./service-repairs");
29
+
30
+ export interface DoctorSelectedRepairResult {
31
+ readonly repair: DoctorRepair;
32
+ readonly invocationError?: string;
33
+ readonly afterChecks?: readonly DoctorCheck[];
34
+ }
35
+
36
+ function risksForAction(action: DoctorAction): DoctorRiskClass[] {
37
+ switch (action) {
38
+ case "config.set-validated":
39
+ case "mcp.set-startup-policy":
40
+ return ["config-change"];
41
+ case "permissions.restrict-owned-config":
42
+ return ["permission-change"];
43
+ case "install.restore-binary":
44
+ return ["install-replace", "network", "external-execution"];
45
+ case "install.repair-managed-link":
46
+ return ["install-replace"];
47
+ case "plugin.restore-known-artifact":
48
+ // The lifecycle authorizer itself requires both; the plan must demand what it
49
+ // enforces. The marketplace family resolves a git source and therefore needs
50
+ // `network` too, which planSelectedDoctorRepair appends once the target is
51
+ // known — this base set alone is not the full enforced set for that family.
52
+ return ["plugin-change", "install-replace"];
53
+ case "plugin.quarantine-selected":
54
+ return ["plugin-change"];
55
+ case "service.restart-owned":
56
+ return ["service-interruption"];
57
+ case "service.detach-owned-stale-artifact":
58
+ return ["artifact-detach"];
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Actions whose apply path is fenced by the crash-recovery journal.
64
+ *
65
+ * The journal's durability primitives (`O_NOFOLLOW`/`flock`-class locking plus
66
+ * fsync of the file and its directory) are only implemented and exercised for
67
+ * Linux and macOS; `DoctorJournalAuthority::createExact` answers
68
+ * `unsupported_platform` on every other host — Windows and the other Unix
69
+ * platforms (FreeBSD, OpenBSD, …) alike. Without the journal there is no record
70
+ * that survives a crash mid-apply, so these repairs are refused up front on
71
+ * unsupported hosts rather than failing after the operator has already
72
+ * authorized a risk class. Diagnosis and `--dry-run` are unaffected — they never
73
+ * open a journal.
74
+ *
75
+ * `install.restore-binary`, `plugin.restore-known-artifact`, and
76
+ * `service.restart-owned` are deliberately absent: they are fenced by their own
77
+ * activation/ownership protocols, not by the journal.
78
+ */
79
+ const JOURNAL_BACKED_ACTIONS = new Set<DoctorAction>([
80
+ "config.set-validated",
81
+ "mcp.set-startup-policy",
82
+ "permissions.restrict-owned-config",
83
+ "install.repair-managed-link",
84
+ "plugin.quarantine-selected",
85
+ "service.detach-owned-stale-artifact",
86
+ ]);
87
+
88
+ /**
89
+ * Whether this host can run the journal-fenced repair lanes at all. Only Linux
90
+ * and macOS carry the native journal implementation, so a journal-backed action
91
+ * is refused before planning/effect on every other platform.
92
+ */
93
+ export function journalRepairSupported(action: DoctorAction, platform: NodeJS.Platform = process.platform): boolean {
94
+ if (!JOURNAL_BACKED_ACTIONS.has(action)) return true;
95
+ return platform === "linux" || platform === "darwin";
96
+ }
97
+
98
+ /** A refusal describing only what was requested; no live target, lock, journal, or candidate is read. */
99
+ function refusedRepair(
100
+ context: DoctorContext,
101
+ before: readonly DoctorCheck[],
102
+ action: DoctorAction,
103
+ targetId: string,
104
+ reasonCode: string,
105
+ readiness: readonly ReadinessFactCode[],
106
+ preconditions: readonly string[],
107
+ ): DoctorRepair {
108
+ return {
109
+ id: action,
110
+ targetId,
111
+ riskClasses: risksForAction(action),
112
+ authorization: [...context.options.allowRisks],
113
+ readiness: [...readiness],
114
+ candidates: [],
115
+ preconditions: [...preconditions],
116
+ state: "blocked",
117
+ reasonCode,
118
+ sideEffectStarted: false,
119
+ beforeCheckIds: before.filter(check => check.targetId === targetId).map(check => check.id),
120
+ afterCheckIds: [],
121
+ restartRequired: false,
122
+ restartScope: "none",
123
+ nonrollbackableEffects: [],
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Refuse the selected action because the diagnosis it would be planned against
129
+ * never settled.
130
+ *
131
+ * A collector that lost its timeout race is not stopped — it keeps running and
132
+ * keeps writing into the shared {@link DoctorContext}. Planning there would read
133
+ * a half-populated target map, so the action is refused before
134
+ * {@link planSelectedDoctorRepair} ever touches that state. Diagnosis output is
135
+ * unaffected: it still reports everything that was collected.
136
+ */
137
+ export function refuseUnsettledDoctorRepair(
138
+ context: DoctorContext,
139
+ before: readonly DoctorCheck[],
140
+ ): DoctorRepair | undefined {
141
+ const { repair: action, targetId } = context.options;
142
+ if (!action || !targetId) return undefined;
143
+ return refusedRepair(
144
+ context,
145
+ before,
146
+ action,
147
+ targetId,
148
+ "incomplete_diagnostics",
149
+ ["target_resolution_incomplete"],
150
+ ["settled diagnosis for every selected collector"],
151
+ );
152
+ }
153
+
154
+ /** Plan data is descriptive only; no domain module, lock, journal, or candidate is materialized here. */
155
+ export function planSelectedDoctorRepair(
156
+ context: DoctorContext,
157
+ before: readonly DoctorCheck[],
158
+ ): DoctorRepair | undefined {
159
+ const { repair: action, targetId } = context.options;
160
+ if (!action || !targetId) return undefined;
161
+ const riskClasses = [...risksForAction(action)];
162
+ const readiness: ReadinessFactCode[] = ["identity_recheck_required"];
163
+ let reasonCode: string | undefined;
164
+ if (!journalRepairSupported(action))
165
+ return refusedRepair(
166
+ context,
167
+ before,
168
+ action,
169
+ targetId,
170
+ "unsupported_platform",
171
+ ["unsupported"],
172
+ ["journal-backed crash recovery"],
173
+ );
174
+ if (
175
+ action === "config.set-validated" ||
176
+ action === "mcp.set-startup-policy" ||
177
+ action === "permissions.restrict-owned-config"
178
+ ) {
179
+ const expectedKind =
180
+ action === "config.set-validated" ? "config" : action === "mcp.set-startup-policy" ? "mcp" : "permission";
181
+ const target = context.targets.get(targetId);
182
+ if (target && target.kind !== expectedKind) reasonCode = "action_target_mismatch";
183
+ if (
184
+ (target?.kind === "mcp" && !target.definitionIsMapping) ||
185
+ before.some(
186
+ check =>
187
+ check.targetId === targetId &&
188
+ ["config_parse_error", "config_root_not_mapping", "config_skills_not_mapping"].includes(
189
+ check.reasonCode ?? "",
190
+ ),
191
+ )
192
+ ) {
193
+ reasonCode = "unsupported_target_shape";
194
+ readiness.push("unsupported");
195
+ }
196
+ if (target && !before.some(check => check.targetId === targetId && check.execution === "completed")) {
197
+ reasonCode = "target_resolution_incomplete";
198
+ readiness.push("target_resolution_incomplete");
199
+ }
200
+ if (!target) {
201
+ const source = context.sources.find(item => item.root.rootId === targetId.split(":")[2]);
202
+ const domainChecks = before.filter(
203
+ check => check.id.startsWith(`${action.split(".")[0]}.`) && (!source || check.scope === source.scope),
204
+ );
205
+ const incomplete =
206
+ source?.root.resolution !== "resolved" ||
207
+ domainChecks.some(
208
+ check =>
209
+ check.execution !== "completed" ||
210
+ check.reasonCode === "config_parse_error" ||
211
+ check.reasonCode === "config_root_not_mapping" ||
212
+ check.reasonCode === "mcp_servers_not_mapping",
213
+ );
214
+ reasonCode = incomplete ? "target_resolution_incomplete" : "unknown_target";
215
+ if (incomplete) readiness.push("target_resolution_incomplete");
216
+ }
217
+ }
218
+ if (action === "service.restart-owned" || action === "service.detach-owned-stale-artifact") {
219
+ const target = context.targets.get(targetId);
220
+ const kind = action === "service.restart-owned" ? "service" : "artifact";
221
+ if (!target) {
222
+ const serviceChecks = before.filter(check => check.id.startsWith("service."));
223
+ const complete =
224
+ targetId.split(":")[2] === context.agentRoot.rootId &&
225
+ context.agentRoot.resolution === "resolved" &&
226
+ serviceChecks.length > 0 &&
227
+ serviceChecks.every(check => check.execution === "completed");
228
+ reasonCode = complete ? "unknown_target" : "target_resolution_incomplete";
229
+ if (!complete) readiness.push("target_resolution_incomplete");
230
+ } else if (target.kind !== kind) reasonCode = "action_target_mismatch";
231
+ else if (
232
+ target.kind === "artifact" &&
233
+ target.slot &&
234
+ !serviceArtifactObservation(target.observation, target.slot)
235
+ ) {
236
+ reasonCode = "unsupported_slot";
237
+ readiness.push("unsupported");
238
+ } else if (!before.some(check => check.targetId === targetId && check.execution === "completed")) {
239
+ reasonCode = "target_resolution_incomplete";
240
+ readiness.push("target_resolution_incomplete");
241
+ }
242
+ }
243
+ if (action === "plugin.quarantine-selected" || action === "plugin.restore-known-artifact") {
244
+ const target = context.targets.get(targetId);
245
+ if (!target) {
246
+ const domain = before.filter(check => check.id.startsWith("plugin."));
247
+ const complete =
248
+ context.sources.some(
249
+ source => source.root.rootId === targetId.split(":")[2] && source.root.resolution === "resolved",
250
+ ) &&
251
+ domain.length > 0 &&
252
+ domain.every(check => check.execution === "completed" && check.reasonCode !== "plugin_registry_invalid");
253
+ reasonCode = complete ? "unknown_target" : "target_resolution_incomplete";
254
+ if (!complete) readiness.push("target_resolution_incomplete");
255
+ } else if (target.kind !== "plugin") reasonCode = "action_target_mismatch";
256
+ else if (
257
+ action === "plugin.quarantine-selected" &&
258
+ (!target.quarantinePlan || target.quarantinePlan.status === "blocked")
259
+ ) {
260
+ reasonCode = target.quarantinePlan?.reason ?? "target_resolution_incomplete";
261
+ readiness.push("target_resolution_incomplete");
262
+ }
263
+ }
264
+ if (action === "install.repair-managed-link") {
265
+ const target = context.targets.get(targetId);
266
+ if (!target) {
267
+ const complete = before.some(
268
+ check => check.id.startsWith("installation.link.") && check.execution === "completed",
269
+ );
270
+ reasonCode = complete ? "unknown_target" : "target_resolution_incomplete";
271
+ if (!complete) readiness.push("target_resolution_incomplete");
272
+ } else if (target.kind !== "link") reasonCode = "action_target_mismatch";
273
+ else if (!target.linkDescriptor?.receiptTrusted) {
274
+ reasonCode = target.linkDescriptor?.reasonCode ?? "ownership_receipt_untrusted";
275
+ readiness.push("unsupported");
276
+ } else if (
277
+ context.options.ref &&
278
+ !target.linkDescriptor.candidates.some(candidate => candidate.id === context.options.ref)
279
+ )
280
+ readiness.push("candidate_unresolved");
281
+ }
282
+ const restoreCandidates: RepairCandidate[] = [];
283
+ if (action === "plugin.restore-known-artifact") {
284
+ const target = context.targets.get(targetId);
285
+ // The marketplace lane resolves a git source, so its authorizer requires
286
+ // `network` on top of the shared classes. Declaring it here surfaces
287
+ // `authorization_missing` pre-effect instead of a mid-action refusal.
288
+ if (target?.kind === "plugin" && target.family === "marketplace") riskClasses.push("network");
289
+ if (!target) {
290
+ const complete = before.some(check => check.id.startsWith("plugin.") && check.execution === "completed");
291
+ reasonCode = complete ? "unknown_target" : "target_resolution_incomplete";
292
+ if (!complete) readiness.push("target_resolution_incomplete");
293
+ } else if (target.kind !== "plugin") reasonCode = "action_target_mismatch";
294
+ else if (target.family === "npm") {
295
+ // One flat shared node_modules layout cannot bound a per-plugin artifact restore.
296
+ reasonCode = "unsupported_shared_layout";
297
+ readiness.push("unsupported");
298
+ } else if (!target.restoreRef) {
299
+ reasonCode = "source_unpinnable";
300
+ readiness.push("unsupported");
301
+ } else if (target.restoreArtifactDigest)
302
+ // What `sha256` names depends on the channel, and an operator pastes it
303
+ // verbatim into `--sha256`. For "stored-source" it is the artifact-tree digest
304
+ // of the stored source; for "catalog" it is the marketplace catalog
305
+ // fingerprint, because the artifact being repaired cannot pin itself — there
306
+ // the installed BYTES are bound instead by the apply path recomputing the
307
+ // staged digest. Either way the registry's baseline fingerprint is deliberately
308
+ // NOT used: it mixes in `source.resolvedAt`, so it changes per install and can
309
+ // never be authorized ahead of time.
310
+ restoreCandidates.push({
311
+ id: target.restoreRef,
312
+ sourceChannel: target.family === "marketplace" ? "catalog" : "stored-source",
313
+ ref: target.restoreRef,
314
+ sha256: target.restoreArtifactDigest,
315
+ evidence: {
316
+ artifactStatus:
317
+ target.restorePlan?.artifact.status ?? target.marketplaceRestorePlan?.artifact.status ?? "absent",
318
+ sourceClass: target.family,
319
+ },
320
+ });
321
+ }
322
+ if (action === "install.restore-binary") {
323
+ const target = context.targets.get(targetId);
324
+ if (!target) {
325
+ reasonCode =
326
+ context.installRoot.resolution === "resolved" &&
327
+ before.some(check => check.id === "installation.current" && check.execution === "completed")
328
+ ? "unknown_target"
329
+ : "target_resolution_incomplete";
330
+ if (reasonCode === "target_resolution_incomplete") readiness.push("target_resolution_incomplete");
331
+ } else if (target.kind !== "binary") reasonCode = "action_target_mismatch";
332
+ else if (!target.installDescriptor?.owned) {
333
+ reasonCode = "source_not_owned_standalone";
334
+ readiness.push("unsupported");
335
+ }
336
+ }
337
+ if (riskClasses.some(risk => !context.options.allowRisks.includes(risk))) readiness.push("authorization_missing");
338
+ if (!context.options.yes && !context.options.tty) readiness.push("confirmation_required");
339
+ if (
340
+ (action === "install.restore-binary" || action === "plugin.restore-known-artifact") &&
341
+ (!context.options.ref || !context.options.sha256)
342
+ )
343
+ readiness.push("pin_missing");
344
+ if (action === "install.repair-managed-link" && !context.options.ref) readiness.push("candidate_selection_missing");
345
+ return {
346
+ id: action,
347
+ targetId,
348
+ riskClasses,
349
+ authorization: [...context.options.allowRisks],
350
+ readiness,
351
+ candidates: restoreCandidates,
352
+ preconditions: ["exact current target", "explicit action risk authorization", "fresh independent postcheck"],
353
+ state: reasonCode ? "blocked" : "planned",
354
+ reasonCode,
355
+ sideEffectStarted: false,
356
+ beforeCheckIds: before.filter(check => check.targetId === targetId).map(check => check.id),
357
+ afterCheckIds: [],
358
+ restartRequired: action === "config.set-validated" || action === "mcp.set-startup-policy",
359
+ restartScope: action === "config.set-validated" || action === "mcp.set-startup-policy" ? "new-session" : "none",
360
+ nonrollbackableEffects: [],
361
+ };
362
+ }
363
+
364
+ function admitSelectedRepair(context: DoctorContext, plan: DoctorRepair): DoctorSelectedRepairResult | undefined {
365
+ if (context.options.signal?.aborted) return { repair: { ...plan, state: "blocked", reasonCode: "cancelled" } };
366
+ if (performance.now() >= context.deadline)
367
+ return { repair: { ...plan, state: "blocked", reasonCode: "deadline_exceeded" } };
368
+ try {
369
+ context.options.onRepairAdmitted?.();
370
+ } catch {
371
+ return { repair: { ...plan, state: "blocked", reasonCode: "repair_admission_unavailable" } };
372
+ }
373
+ return undefined;
374
+ }
375
+
376
+ /**
377
+ * Private-marketplace D6 lane. Same gate as every other action: explicit pins
378
+ * matched against the recorded provenance, confirmation, risk authorization,
379
+ * admission, then an independent post-check. Authorization is the first
380
+ * effect-capable step because it resolves and stages the pinned source.
381
+ */
382
+ async function executeMarketplaceRestore(
383
+ context: DoctorContext,
384
+ plan: DoctorRepair,
385
+ target: DoctorPluginTarget,
386
+ collectAfterChecks: () => Promise<readonly DoctorCheck[]>,
387
+ ): Promise<DoctorSelectedRepairResult> {
388
+ if (!target.restoreRef)
389
+ return { repair: { ...plan, state: "blocked", reasonCode: "source_unpinnable", readiness: ["unsupported"] } };
390
+ if (context.options.ref !== target.restoreRef)
391
+ return { repair: { ...plan, state: "blocked", reasonCode: "candidate_provenance_mismatch" } };
392
+ const original = target.marketplaceRestorePlan;
393
+ if (!original) return { repair: { ...plan, state: "blocked", reasonCode: "original_target_missing" } };
394
+ // The pin binds the catalog entry the operator authorized, which is defined
395
+ // even when the artifact is absent. The candidate BYTES are bound separately
396
+ // and more strongly: the apply path recomputes the resolved source digest
397
+ // itself, so this gate can never be the only thing standing between an
398
+ // operator and unintended content.
399
+ if (
400
+ !target.restoreArtifactDigest ||
401
+ context.options.sha256?.toLowerCase() !== target.restoreArtifactDigest.toLowerCase()
402
+ )
403
+ return { repair: { ...plan, state: "blocked", reasonCode: "candidate_digest_mismatch" } };
404
+ // `artifact.status === "present"` only means a readable tree exists; a corrupt
405
+ // artifact is still present. The lane's own apply performs the authoritative
406
+ // digest comparison and returns a true zero-effect `not_needed`, so no
407
+ // short-circuit is taken here.
408
+ if (!context.options.yes) {
409
+ const confirmation = await confirmSelectedRepair(context, plan);
410
+ if (confirmation !== "confirmed")
411
+ return {
412
+ repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
413
+ };
414
+ }
415
+ const admissionFailure = admitSelectedRepair(context, plan);
416
+ if (admissionFailure) return admissionFailure;
417
+ // Authorization resolves the catalog and stages a candidate; `apply` is the
418
+ // first call that can touch installed state. Anything that throws before it —
419
+ // including plain errors from catalog reads or source resolution — is a
420
+ // pre-effect refusal and must never be reported as an uncertain mutation.
421
+ let manager: MarketplaceManager;
422
+ let token: MarketplaceReviewedRestoreTokenV1;
423
+ try {
424
+ manager = await createDoctorMarketplaceManager(context.cwd);
425
+ token = (await runPluginRestoreAction({
426
+ kind: "marketplace-authorize",
427
+ manager,
428
+ plan: original,
429
+ authorizations: context.options.allowRisks,
430
+ })) as MarketplaceReviewedRestoreTokenV1;
431
+ } catch (error) {
432
+ return {
433
+ repair: {
434
+ ...plan,
435
+ state: "blocked",
436
+ sideEffectStarted: false,
437
+ reasonCode: error instanceof MarketplaceRestoreError ? error.reasonCode : "restore_authorization_failed",
438
+ },
439
+ };
440
+ }
441
+ try {
442
+ // The token must still describe the exact plan the operator authorized.
443
+ if (
444
+ token.purpose !== "restore-artifact" ||
445
+ token.pluginId !== target.name ||
446
+ token.scope !== target.scope ||
447
+ token.baselineFingerprint !== original.baselineFingerprint ||
448
+ token.catalogFingerprint !== original.catalogFingerprint
449
+ )
450
+ return { repair: { ...plan, state: "blocked", reasonCode: "stale_baseline" } };
451
+ const applied = (await runPluginRestoreAction({
452
+ kind: "marketplace-apply",
453
+ manager,
454
+ token,
455
+ })) as MarketplaceRestoreApplyResult;
456
+ const afterChecks = await collectAfterChecks();
457
+ const restored = afterChecks.some(
458
+ check =>
459
+ check.targetId === target.targetId &&
460
+ check.execution === "completed" &&
461
+ check.evidence.artifactStatus === "present",
462
+ );
463
+ const success = (applied.status === "verified" || applied.status === "not_needed") && restored;
464
+ return {
465
+ repair: {
466
+ ...plan,
467
+ state: success ? (applied.status === "not_needed" ? "not_needed" : "verified") : "uncertain",
468
+ readiness: [],
469
+ sideEffectStarted: applied.sideEffectStarted,
470
+ reasonCode: success ? undefined : "restore_postcheck_unverified",
471
+ ...(success ? { outcome: { mutationVerified: true } } : {}),
472
+ },
473
+ afterChecks,
474
+ };
475
+ } catch (error) {
476
+ // The lane's typed error distinguishes a pre-effect refusal from an
477
+ // effect-started outcome; never collapse the two.
478
+ if (error instanceof MarketplaceRestoreError)
479
+ return {
480
+ repair: {
481
+ ...plan,
482
+ state: error.sideEffectStarted
483
+ ? error.outcome === "conflict"
484
+ ? "rollback_conflict"
485
+ : "uncertain"
486
+ : "blocked",
487
+ sideEffectStarted: error.sideEffectStarted,
488
+ reasonCode: error.reasonCode,
489
+ },
490
+ };
491
+ return {
492
+ repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
493
+ };
494
+ } finally {
495
+ // Authorization already copied the whole plugin tree into a temp staging dir,
496
+ // and the lane deliberately never deletes it: that would be a write inside a
497
+ // branch which must stay zero-effect, so disposal is the caller's contract.
498
+ // It must happen on EVERY exit, not just the zero-effect one — a refusal
499
+ // leaks just as much as a no-op. On the success path the directory was
500
+ // renamed away, which `force` makes a no-op.
501
+ await fsp.rm(token.stagedArtifactPath, { recursive: true, force: true }).catch(() => {});
502
+ }
503
+ }
504
+
505
+ export async function executeSelectedDoctorRepair(
506
+ context: DoctorContext,
507
+ runId: string,
508
+ plan: DoctorRepair,
509
+ collectAfterChecks: () => Promise<readonly DoctorCheck[]>,
510
+ ): Promise<DoctorSelectedRepairResult> {
511
+ if (context.options.mode !== "fix") return { repair: plan };
512
+ if (plan.state === "blocked")
513
+ return {
514
+ repair: plan,
515
+ ...(plan.reasonCode === "unknown_target" || plan.reasonCode === "action_target_mismatch"
516
+ ? { invocationError: plan.reasonCode }
517
+ : {}),
518
+ };
519
+ if (isDoctorAction(plan.id) && !journalRepairSupported(plan.id))
520
+ return {
521
+ repair: {
522
+ ...plan,
523
+ state: "blocked",
524
+ reasonCode: "unsupported_platform",
525
+ readiness: ["unsupported"],
526
+ sideEffectStarted: false,
527
+ },
528
+ };
529
+ if (context.options.signal?.aborted) return { repair: { ...plan, state: "blocked", reasonCode: "cancelled" } };
530
+ if (performance.now() >= context.deadline)
531
+ return { repair: { ...plan, state: "blocked", reasonCode: "deadline_exceeded" } };
532
+ if (
533
+ plan.readiness.includes("authorization_missing") ||
534
+ plan.readiness.includes("confirmation_required") ||
535
+ plan.readiness.includes("pin_missing") ||
536
+ plan.readiness.includes("candidate_selection_missing")
537
+ )
538
+ return { repair: { ...plan, state: "blocked", reasonCode: "repair_preflight_incomplete" } };
539
+ if (
540
+ plan.id !== "config.set-validated" &&
541
+ plan.id !== "mcp.set-startup-policy" &&
542
+ plan.id !== "permissions.restrict-owned-config" &&
543
+ plan.id !== "service.detach-owned-stale-artifact" &&
544
+ plan.id !== "plugin.quarantine-selected" &&
545
+ plan.id !== "install.restore-binary" &&
546
+ plan.id !== "install.repair-managed-link" &&
547
+ plan.id !== "service.restart-owned" &&
548
+ plan.id !== "plugin.restore-known-artifact"
549
+ )
550
+ return {
551
+ repair: { ...plan, state: "blocked", reasonCode: "repair_dispatch_unavailable", readiness: ["unsupported"] },
552
+ };
553
+ const target = context.targets.get(plan.targetId);
554
+ if (!target) {
555
+ const knownRoot = context.sources.some(source => plan.targetId.split(":")[2] === source.root.rootId);
556
+ return {
557
+ repair: {
558
+ ...plan,
559
+ state: "blocked",
560
+ reasonCode: "target_resolution_incomplete",
561
+ readiness: ["target_resolution_incomplete"],
562
+ },
563
+ ...(knownRoot ? { invocationError: "unknown_target" } : {}),
564
+ };
565
+ }
566
+ if (plan.id === "install.repair-managed-link") {
567
+ if (target.kind !== "link" || !target.linkDescriptor || !context.options.ref)
568
+ return { repair: { ...plan, state: "blocked", reasonCode: "candidate_selection_missing" } };
569
+ if (!context.options.yes) {
570
+ const confirmation = await confirmSelectedRepair(context, plan);
571
+ if (confirmation !== "confirmed")
572
+ return {
573
+ repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
574
+ };
575
+ }
576
+ const admissionFailure = admitSelectedRepair(context, plan);
577
+ if (admissionFailure) return admissionFailure;
578
+ try {
579
+ const descriptor = target.linkDescriptor;
580
+ const result = await repairManagedLink(
581
+ {
582
+ targetPath: target.filePath,
583
+ alias: descriptor.alias,
584
+ root: path.resolve(path.dirname(context.cliPath ?? ""), "..", "..", ".."),
585
+ ref: context.options.ref,
586
+ mode: "fix",
587
+ allowRisks: context.options.allowRisks,
588
+ yes: true,
589
+ journalRoot: context.agentRoot.locator,
590
+ runId,
591
+ },
592
+ descriptor,
593
+ );
594
+ const afterChecks = await collectAfterChecks();
595
+ const healthy = afterChecks.some(
596
+ check =>
597
+ check.targetId === target.targetId &&
598
+ check.execution === "completed" &&
599
+ check.evidence.status === "healthy",
600
+ );
601
+ const success = (result.state === "verified" || result.state === "not_needed") && healthy;
602
+ const state: DoctorRepair["state"] = success
603
+ ? result.state === "not_needed"
604
+ ? "not_needed"
605
+ : "verified"
606
+ : result.state === "rolled_back" || result.state === "rollback_conflict"
607
+ ? result.state
608
+ : result.sideEffectStarted
609
+ ? "uncertain"
610
+ : "blocked";
611
+ return {
612
+ repair: {
613
+ ...plan,
614
+ state,
615
+ readiness: [],
616
+ reasonCode: result.reasonCode ?? (success ? undefined : "managed_link_postcheck_unverified"),
617
+ sideEffectStarted: result.sideEffectStarted,
618
+ ...(success ? { outcome: { mutationVerified: true } } : {}),
619
+ },
620
+ afterChecks,
621
+ };
622
+ } catch {
623
+ return {
624
+ repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
625
+ };
626
+ }
627
+ }
628
+ if (plan.id === "install.restore-binary") {
629
+ if (target.kind !== "binary" || !target.installDescriptor || !context.options.ref || !context.options.sha256)
630
+ return { repair: { ...plan, state: "blocked", reasonCode: "original_snapshot_incomplete" } };
631
+ if (!context.options.yes) {
632
+ const confirmation = await confirmSelectedRepair(context, plan);
633
+ if (confirmation !== "confirmed")
634
+ return {
635
+ repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
636
+ };
637
+ }
638
+ const admissionFailure = admitSelectedRepair(context, plan);
639
+ if (admissionFailure) return admissionFailure;
640
+ try {
641
+ const descriptor = target.installDescriptor;
642
+ const result = await repairStandaloneBinary(
643
+ target.filePath,
644
+ descriptor,
645
+ {
646
+ ref: context.options.ref,
647
+ sha256: context.options.sha256,
648
+ channel: descriptor.channel,
649
+ version: descriptor.version,
650
+ os: process.platform,
651
+ arch: process.arch,
652
+ },
653
+ context.options.allowRisks,
654
+ );
655
+ const afterChecks = await collectAfterChecks();
656
+ const checked = afterChecks.some(
657
+ check =>
658
+ check.targetId === target.targetId &&
659
+ check.execution === "completed" &&
660
+ check.evidence.integrityResult === "matches_pin",
661
+ );
662
+ const success = (result.state === "verified" || result.state === "not_needed") && checked;
663
+ const state: DoctorRepair["state"] =
664
+ result.state === "pending_activation"
665
+ ? "pending_activation"
666
+ : success
667
+ ? result.state === "not_needed"
668
+ ? "not_needed"
669
+ : "verified"
670
+ : result.sideEffectStarted
671
+ ? "uncertain"
672
+ : "blocked";
673
+ return {
674
+ repair: {
675
+ ...plan,
676
+ state,
677
+ readiness: [],
678
+ reasonCode: result.reason ?? (success ? undefined : "installation_postcheck_unverified"),
679
+ sideEffectStarted: result.sideEffectStarted,
680
+ ...(success ? { outcome: { mutationVerified: true } } : {}),
681
+ restartRequired: result.state === "pending_activation",
682
+ restartScope: result.state === "pending_activation" ? "new-session" : "none",
683
+ },
684
+ afterChecks,
685
+ };
686
+ } catch {
687
+ return {
688
+ repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
689
+ };
690
+ }
691
+ }
692
+ if (plan.id === "plugin.quarantine-selected") {
693
+ if (target.kind !== "plugin" || !target.quarantinePlan)
694
+ return { repair: { ...plan, state: "blocked", reasonCode: "target_resolution_incomplete" } };
695
+ if (!context.options.yes) {
696
+ const confirmation = await confirmSelectedRepair(context, plan);
697
+ if (confirmation !== "confirmed")
698
+ return {
699
+ repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
700
+ };
701
+ }
702
+ const admissionFailure = admitSelectedRepair(context, plan);
703
+ if (admissionFailure) return admissionFailure;
704
+ try {
705
+ const result = await applyPluginQuarantine(
706
+ {
707
+ family: target.family,
708
+ scope: target.scope,
709
+ name: target.name,
710
+ cwd: context.cwd,
711
+ home: os.homedir(),
712
+ rootId: target.root.rootId,
713
+ registryPath: target.registryPath,
714
+ journalRoot: context.agentRoot.locator,
715
+ runId,
716
+ },
717
+ context.options.allowRisks,
718
+ target.quarantinePlan,
719
+ );
720
+ const afterChecks = await collectAfterChecks();
721
+ const verified =
722
+ result.targetExcludedFromStartup === true &&
723
+ afterChecks.some(
724
+ check =>
725
+ check.targetId === target.targetId &&
726
+ check.execution === "completed" &&
727
+ check.evidence.enabled === false,
728
+ );
729
+ const success = (result.status === "verified" || result.status === "not_needed") && verified;
730
+ const state: DoctorRepair["state"] = success
731
+ ? result.status === "not_needed"
732
+ ? "not_needed"
733
+ : "verified"
734
+ : result.sideEffectStarted
735
+ ? "uncertain"
736
+ : "blocked";
737
+ const reasonCode =
738
+ result.reason && /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(result.reason)
739
+ ? result.reason
740
+ : success
741
+ ? undefined
742
+ : "plugin_quarantine_unverified";
743
+ return {
744
+ repair: {
745
+ ...plan,
746
+ state,
747
+ readiness: [],
748
+ reasonCode,
749
+ sideEffectStarted: result.sideEffectStarted,
750
+ ...(success ? { outcome: { mutationVerified: true } } : {}),
751
+ },
752
+ afterChecks,
753
+ };
754
+ } catch {
755
+ return {
756
+ repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
757
+ };
758
+ }
759
+ }
760
+ if (plan.id === "plugin.restore-known-artifact" && target.kind === "plugin" && target.family === "marketplace") {
761
+ return await executeMarketplaceRestore(context, plan, target, collectAfterChecks);
762
+ }
763
+ if (plan.id === "plugin.restore-known-artifact") {
764
+ if (target.kind !== "plugin")
765
+ return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
766
+ // npm plugins share one flat node_modules layout, so a per-plugin artifact
767
+ // restore cannot be bounded to the selected target. That is the plan's own
768
+ // distinct refusal, not a general family gap.
769
+ if (target.family === "npm")
770
+ return {
771
+ repair: { ...plan, state: "blocked", reasonCode: "unsupported_shared_layout", readiness: ["unsupported"] },
772
+ };
773
+ if (!target.restoreRef)
774
+ return { repair: { ...plan, state: "blocked", reasonCode: "source_unpinnable", readiness: ["unsupported"] } };
775
+ if (context.options.ref !== target.restoreRef)
776
+ return { repair: { ...plan, state: "blocked", reasonCode: "candidate_provenance_mismatch" } };
777
+ const original = target.restorePlan;
778
+ if (!original) return { repair: { ...plan, state: "blocked", reasonCode: "original_target_missing" } };
779
+ // The digest pin binds the exact artifact bytes the operator authorized.
780
+ if (
781
+ !target.restoreArtifactDigest ||
782
+ context.options.sha256?.toLowerCase() !== target.restoreArtifactDigest.toLowerCase()
783
+ )
784
+ return { repair: { ...plan, state: "blocked", reasonCode: "candidate_digest_mismatch" } };
785
+ if (original.artifact.status === "present")
786
+ return { repair: { ...plan, state: "not_needed", reasonCode: "plugin_artifact_present" } };
787
+ if (!context.options.yes) {
788
+ const confirmation = await confirmSelectedRepair(context, plan);
789
+ if (confirmation !== "confirmed")
790
+ return {
791
+ repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
792
+ };
793
+ }
794
+ const admissionFailure = admitSelectedRepair(context, plan);
795
+ if (admissionFailure) return admissionFailure;
796
+ const identity = { kind: GJC_BUNDLE_KIND, scope: target.scope, name: target.name } as const;
797
+ try {
798
+ // Authorization resolves the stored source; it is the first effect-capable step.
799
+ const authorized = await runPluginRestoreAction({
800
+ kind: "authorize",
801
+ target: { identity, cwd: context.cwd },
802
+ plan: original,
803
+ authorizations: context.options.allowRisks,
804
+ });
805
+ if (!("ok" in authorized) || !authorized.ok || !("value" in authorized))
806
+ return {
807
+ repair: {
808
+ ...plan,
809
+ state: "blocked",
810
+ reasonCode:
811
+ "ok" in authorized && !authorized.ok ? authorized.error.code : "restore_authorization_refused",
812
+ },
813
+ };
814
+ const token = authorized.value as ReviewedRestoreTokenV1;
815
+ if (
816
+ token.purpose !== "restore-artifact" ||
817
+ token.identity.name !== target.name ||
818
+ token.identity.scope !== target.scope ||
819
+ token.baselineFingerprint !== original.baselineFingerprint ||
820
+ token.decisionContextFingerprint !== original.decisionContextFingerprint
821
+ )
822
+ return { repair: { ...plan, state: "blocked", reasonCode: "stale_baseline" } };
823
+ const applied = await runPluginRestoreAction({ kind: "apply", target: { identity, cwd: context.cwd }, token });
824
+ if (!("ok" in applied) || !applied.ok)
825
+ return {
826
+ repair: {
827
+ ...plan,
828
+ state: "uncertain",
829
+ sideEffectStarted: true,
830
+ reasonCode: "ok" in applied && !applied.ok ? applied.error.code : "restore_apply_unverified",
831
+ },
832
+ };
833
+ const afterChecks = await collectAfterChecks();
834
+ const restored = afterChecks.some(
835
+ check =>
836
+ check.targetId === target.targetId &&
837
+ check.execution === "completed" &&
838
+ check.evidence.artifactStatus === "present",
839
+ );
840
+ return {
841
+ repair: {
842
+ ...plan,
843
+ state: restored ? "verified" : "uncertain",
844
+ readiness: [],
845
+ sideEffectStarted: true,
846
+ reasonCode: restored ? undefined : "restore_postcheck_unverified",
847
+ ...(restored ? { outcome: { mutationVerified: true } } : {}),
848
+ },
849
+ afterChecks,
850
+ };
851
+ } catch {
852
+ return {
853
+ repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
854
+ };
855
+ }
856
+ }
857
+ if (plan.id === "service.restart-owned") {
858
+ if (target.kind !== "service")
859
+ return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
860
+ const before = parseServiceRecord(target.observation.state);
861
+ if (!before) return { repair: { ...plan, state: "blocked", reasonCode: "service_owner_absent" } };
862
+ if (!context.options.yes) {
863
+ const confirmation = await confirmSelectedRepair(context, plan);
864
+ if (confirmation !== "confirmed")
865
+ return {
866
+ repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
867
+ };
868
+ }
869
+ const admissionFailure = admitSelectedRepair(context, plan);
870
+ if (admissionFailure) return admissionFailure;
871
+ let settings: Settings | undefined;
872
+ try {
873
+ const drainSeconds = context.options.drainSeconds;
874
+ const deadlineMs = Math.max(
875
+ 1_000,
876
+ // `context.deadline` is monotonic (performance.now); mixing it with Date.now
877
+ // yields a large negative remainder that silently clamps every restart to the
878
+ // 1s floor and defeats --drain.
879
+ Math.min(context.deadline - performance.now(), 15_000 + (drainSeconds ?? 0) * 1_000),
880
+ );
881
+ // The owner's own refusal reason is kept alongside the coarse stage: an
882
+ // operator needs to tell "nothing is running" apart from "this daemon
883
+ // predates the restart protocol and needs the manual transition".
884
+ let outcome: { readonly kind: string; readonly reason?: string; readonly successorIncarnation?: string };
885
+ if (target.service === "broker") {
886
+ const result = await restartBrokerForDoctor({
887
+ agentDir: context.agentRoot.locator,
888
+ requestId: runId,
889
+ deadlineMs,
890
+ drain: drainSeconds !== undefined,
891
+ });
892
+ outcome =
893
+ result.kind === "restarted"
894
+ ? { kind: result.kind, successorIncarnation: result.successor.incarnation }
895
+ : { kind: result.kind, ...("reason" in result ? { reason: result.reason } : {}) };
896
+ } else {
897
+ settings = await Settings.loadForScope({ cwd: context.cwd, agentDir: context.agentRoot.locator });
898
+ const result = await restartDaemonForDoctor({
899
+ agentDir: context.agentRoot.locator,
900
+ owner: target.service,
901
+ settings,
902
+ requestId: runId,
903
+ deadlineMs,
904
+ ...(drainSeconds === undefined ? {} : { drainSeconds }),
905
+ });
906
+ outcome =
907
+ result.kind === "restarted"
908
+ ? { kind: result.kind, successorIncarnation: result.successor.incarnation }
909
+ : { kind: result.kind, ...("reason" in result ? { reason: result.reason } : {}) };
910
+ }
911
+ const afterChecks = await collectAfterChecks();
912
+ const republished = afterChecks.some(
913
+ check =>
914
+ check.targetId === target.targetId && check.execution === "completed" && check.evidence.present === true,
915
+ );
916
+ // A restart is only verified when the published owner is a DIFFERENT incarnation
917
+ // than the one observed before; a surviving incumbent is never success. The
918
+ // successor identity is re-read from the refreshed target rather than taken
919
+ // from the mutation's own return value, so the claim rests on this pass's
920
+ // independent observation.
921
+ // `collectAfterChecks` runs in a fresh context, so re-observe the published
922
+ // record here rather than reading the pre-restart snapshot still held in
923
+ // `context.targets`.
924
+ // A daemon rewrites its heartbeat by rename, so a read can legitimately land
925
+ // mid-swap and report `changed`. That is a torn read, not evidence, and it
926
+ // would downgrade a real restart to uncertain; retry it exactly once.
927
+ let observed = (await readDoctorService(context.agentRoot.locator, target.service)).state;
928
+ if (observed.status === "changed")
929
+ observed = (await readDoctorService(context.agentRoot.locator, target.service)).state;
930
+ const published = parseServiceRecord(observed)?.incarnation;
931
+ const replaced =
932
+ typeof before.incarnation === "string" &&
933
+ typeof published === "string" &&
934
+ published !== before.incarnation &&
935
+ outcome.successorIncarnation === published;
936
+ if (outcome.kind === "restarted" && republished && replaced)
937
+ return {
938
+ repair: {
939
+ ...plan,
940
+ state: "verified",
941
+ readiness: [],
942
+ sideEffectStarted: true,
943
+ outcome: { mutationVerified: true },
944
+ restartRequired: false,
945
+ restartScope: "none",
946
+ },
947
+ afterChecks,
948
+ };
949
+ const preEffect = outcome.kind === "owner_unavailable" || outcome.kind === "prepare_refused";
950
+ return {
951
+ repair: {
952
+ ...plan,
953
+ state: preEffect ? "blocked" : "uncertain",
954
+ readiness: [],
955
+ sideEffectStarted: !preEffect,
956
+ reasonCode:
957
+ outcome.kind === "restarted"
958
+ ? "service_restart_postcheck_unverified"
959
+ : outcome.reason === undefined
960
+ ? outcome.kind
961
+ : `${outcome.kind}:${outcome.reason}`,
962
+ },
963
+ afterChecks,
964
+ };
965
+ } catch {
966
+ return {
967
+ repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
968
+ };
969
+ } finally {
970
+ await settings?.close();
971
+ }
972
+ }
973
+ if (plan.id === "service.detach-owned-stale-artifact") {
974
+ if (target.kind !== "artifact" || !target.slot)
975
+ return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
976
+ if (serviceArtifactObservation(target.observation, target.slot)?.status === "missing") {
977
+ const afterChecks = await collectAfterChecks();
978
+ if (
979
+ !afterChecks.some(
980
+ check =>
981
+ check.targetId === target.targetId &&
982
+ check.execution === "completed" &&
983
+ check.evidenceLevel === "observed" &&
984
+ check.evidence.present === false,
985
+ )
986
+ )
987
+ return { repair: { ...plan, state: "blocked", reasonCode: "artifact_absence_unverified" }, afterChecks };
988
+ return {
989
+ repair: { ...plan, state: "not_needed", readiness: [], outcome: { mutationVerified: true } },
990
+ afterChecks,
991
+ };
992
+ }
993
+ if (!context.options.yes) {
994
+ const confirmation = await confirmSelectedRepair(context, plan);
995
+ if (confirmation !== "confirmed")
996
+ return {
997
+ repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
998
+ };
999
+ }
1000
+ const module = await loadServiceRepair().catch(() => undefined);
1001
+ if (!module)
1002
+ return {
1003
+ repair: { ...plan, state: "blocked", reasonCode: "repair_module_unavailable", readiness: ["unsupported"] },
1004
+ };
1005
+ const admissionFailure = admitSelectedRepair(context, plan);
1006
+ if (admissionFailure) return admissionFailure;
1007
+ try {
1008
+ return await module.applyDoctorStaleArtifact(context, runId, target, collectAfterChecks);
1009
+ } catch {
1010
+ return {
1011
+ repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
1012
+ };
1013
+ }
1014
+ }
1015
+ if (plan.id === "permissions.restrict-owned-config") {
1016
+ if (target.kind !== "permission")
1017
+ return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
1018
+ if (target.observation.status !== "read" || target.source.root.resolution !== "resolved")
1019
+ return {
1020
+ repair: {
1021
+ ...plan,
1022
+ state: "blocked",
1023
+ reasonCode: "target_resolution_incomplete",
1024
+ readiness: ["target_resolution_incomplete"],
1025
+ },
1026
+ };
1027
+ if (!context.options.yes) {
1028
+ const confirmation = await confirmSelectedRepair(context, plan);
1029
+ if (confirmation !== "confirmed")
1030
+ return {
1031
+ repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
1032
+ };
1033
+ }
1034
+ const module = await loadPermissionRepair().catch(() => undefined);
1035
+ if (!module)
1036
+ return {
1037
+ repair: { ...plan, state: "blocked", reasonCode: "repair_module_unavailable", readiness: ["unsupported"] },
1038
+ };
1039
+ const admissionFailure = admitSelectedRepair(context, plan);
1040
+ if (admissionFailure) return admissionFailure;
1041
+ try {
1042
+ const result = await module.applyDoctorPermissionRepair({
1043
+ filePath: target.source.configPath,
1044
+ rootPath: target.source.root.locator,
1045
+ scope: target.source.scope,
1046
+ targetId: target.targetId,
1047
+ repairId: plan.id,
1048
+ runId,
1049
+ mode: "fix",
1050
+ authorization: context.options.allowRisks,
1051
+ journalRoot: context.agentRoot.locator,
1052
+ expected: { identity: target.observation.exactIdentity, mode: target.observation.identity.mode },
1053
+ collectAfterChecks,
1054
+ });
1055
+ return { repair: result.repair, afterChecks: result.afterChecks };
1056
+ } catch {
1057
+ return {
1058
+ repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
1059
+ };
1060
+ }
1061
+ }
1062
+ if (
1063
+ (plan.id === "config.set-validated" && target.kind !== "config") ||
1064
+ (plan.id === "mcp.set-startup-policy" && target.kind !== "mcp")
1065
+ )
1066
+ return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
1067
+ if (target.kind !== "config" && target.kind !== "mcp")
1068
+ return { repair: { ...plan, state: "blocked" }, invocationError: "action_target_mismatch" };
1069
+ if (target.observation.status !== "read" || target.source.root.resolution !== "resolved")
1070
+ return {
1071
+ repair: {
1072
+ ...plan,
1073
+ state: "blocked",
1074
+ reasonCode: "target_resolution_incomplete",
1075
+ readiness: ["target_resolution_incomplete"],
1076
+ },
1077
+ };
1078
+ if (typeof context.options.setValue !== "boolean")
1079
+ return { repair: { ...plan, state: "blocked" }, invocationError: "value_required" };
1080
+ if (!context.options.yes) {
1081
+ const confirmation = await confirmSelectedRepair(context, plan);
1082
+ if (confirmation !== "confirmed")
1083
+ return {
1084
+ repair: { ...plan, state: "blocked", reasonCode: confirmation, readiness: ["confirmation_required"] },
1085
+ };
1086
+ }
1087
+ const request: DoctorConfigRepairRequest = {
1088
+ filePath: target.kind === "config" ? target.source.configPath : target.source.mcpPath,
1089
+ rootPath: target.source.root.locator,
1090
+ scope: target.source.scope,
1091
+ targetId: target.targetId,
1092
+ repairId: plan.id,
1093
+ runId,
1094
+ mode: "fix",
1095
+ authorization: context.options.allowRisks,
1096
+ journalRoot: context.agentRoot.locator,
1097
+ expected: {
1098
+ raw: target.observation.text,
1099
+ observedValue: target.beforeValue,
1100
+ identity: target.observation.exactIdentity,
1101
+ },
1102
+ kind: target.kind === "config" ? "skill" : "mcp",
1103
+ ...(target.kind === "config"
1104
+ ? { schemaKey: target.schemaKey }
1105
+ : { serverName: target.serverName, field: target.field }),
1106
+ value: context.options.setValue,
1107
+ collectAfterChecks,
1108
+ };
1109
+ const module = await loadConfigRepair().catch(() => undefined);
1110
+ if (!module)
1111
+ return {
1112
+ repair: { ...plan, state: "blocked", reasonCode: "repair_module_unavailable", readiness: ["unsupported"] },
1113
+ };
1114
+ const admissionFailure = admitSelectedRepair(context, plan);
1115
+ if (admissionFailure) return admissionFailure;
1116
+ let result: DoctorConfigRepairResult;
1117
+ try {
1118
+ result = await module.applyDoctorConfigRepair(request);
1119
+ } catch {
1120
+ return {
1121
+ repair: { ...plan, state: "uncertain", sideEffectStarted: true, reasonCode: "repair_execution_unverified" },
1122
+ };
1123
+ }
1124
+ return {
1125
+ repair: {
1126
+ ...result.repair,
1127
+ restartScope: "new-session",
1128
+ beforeCheckIds: plan.beforeCheckIds,
1129
+ },
1130
+ afterChecks: result.afterChecks,
1131
+ };
1132
+ }
1133
+
1134
+ async function confirmSelectedRepair(context: DoctorContext, plan: DoctorRepair): Promise<DoctorConfirmationResult> {
1135
+ if (context.options.confirmRepair) return context.options.confirmRepair(plan);
1136
+ const terminal = createInterface({ input: process.stdin, output: process.stderr });
1137
+ const interrupt = new AbortController();
1138
+ const onInterrupt = () => interrupt.abort();
1139
+ terminal.once("SIGINT", onInterrupt);
1140
+ const remaining = Math.max(1, Math.floor(context.deadline - performance.now()));
1141
+ const signals = [interrupt.signal, AbortSignal.timeout(remaining)];
1142
+ if (context.options.signal) signals.push(context.options.signal);
1143
+ try {
1144
+ const answer = await terminal.question(
1145
+ `Apply ${plan.id} to ${plan.targetId}${context.options.setValue === undefined ? "" : `, value ${context.options.setValue}`}? [y/N] `,
1146
+ { signal: AbortSignal.any(signals) },
1147
+ );
1148
+ return /^(y|yes)$/i.test(answer.trim()) ? "confirmed" : "confirmation_declined";
1149
+ } catch (error) {
1150
+ if (error instanceof Error && error.name === "AbortError")
1151
+ return interrupt.signal.aborted || context.options.signal?.aborted ? "cancelled" : "confirmation_timeout";
1152
+ throw error;
1153
+ } finally {
1154
+ terminal.off("SIGINT", onInterrupt);
1155
+ terminal.close();
1156
+ }
1157
+ }