@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,666 @@
1
+ /**
2
+ * Shared target-adjacent v1 activation record authority for `gjc update` and
3
+ * `gjc doctor install.restore-binary`.
4
+ *
5
+ * Every identity capture is a bounded, no-follow, post-read-revalidated
6
+ * snapshot, never a bare fs.readFile/fs.stat pair. Every record write is a
7
+ * no-replace create or an exact CAS against the caller's own previously
8
+ * observed record identity, never a blind fs.rename overwrite. Promoting a
9
+ * staged candidate onto the live target uses the native exactReplaceRetained
10
+ * primitive: it validates both identities before mutating anything and
11
+ * retires the old target to a retained backup name instead of deleting it.
12
+ * There is no unlink/rename-based rollback path in this module.
13
+ */
14
+
15
+ import { createHash, randomUUID } from "node:crypto";
16
+ import { constants } from "node:fs";
17
+ import * as fs from "node:fs/promises";
18
+ import * as path from "node:path";
19
+ import {
20
+ exactReplacePath,
21
+ exactReplaceRetained,
22
+ exactRestore,
23
+ type NativeExactFileIdentity,
24
+ } from "@gajae-code/natives";
25
+
26
+ export type ActivationPhase = "staged" | "published" | "verified" | "pending_activation" | "rolled_back" | "uncertain";
27
+
28
+ /** Serialized exact regular-file identity. BigInt fields are decimal strings so the record stays plain JSON. */
29
+ export interface FileIdentity {
30
+ readonly dev: string;
31
+ readonly ino: string;
32
+ readonly nlink: string;
33
+ readonly parentDev?: string;
34
+ readonly parentIno?: string;
35
+ readonly size: string;
36
+ readonly mtimeNs: string;
37
+ readonly mode: number;
38
+ readonly sha256: string;
39
+ }
40
+
41
+ /** Serialized directory identity. No content hash: a directory has no bytes to bind. */
42
+ export interface DirectoryIdentity {
43
+ readonly dev: string;
44
+ readonly ino: string;
45
+ readonly mode: number;
46
+ }
47
+
48
+ export interface ActivationRecordV1 {
49
+ readonly schemaVersion: 1;
50
+ readonly kind: "gjc-install-activation";
51
+ readonly transactionId: string;
52
+ readonly targetPath: string;
53
+ readonly parentPath: string;
54
+ readonly targetIdentity?: FileIdentity;
55
+ readonly backupIdentity?: FileIdentity;
56
+ readonly parentIdentity?: DirectoryIdentity;
57
+ readonly baseline: { readonly exists: boolean; readonly digest?: string; readonly version?: string };
58
+ readonly candidate: {
59
+ readonly digest: string;
60
+ readonly version: string;
61
+ readonly channel?: string;
62
+ readonly ref?: string;
63
+ readonly os?: string;
64
+ readonly arch?: string;
65
+ readonly sidecars: readonly string[];
66
+ };
67
+ readonly stagingPath: string;
68
+ /** Staged candidate identity captured when recorded; re-checked before promotion. */
69
+ readonly stagingIdentity?: FileIdentity;
70
+ /** Single-component name exactReplaceRetained retires the old target to, inside the target's own parent. */
71
+ readonly backupName: string;
72
+ readonly phase: ActivationPhase;
73
+ }
74
+
75
+ export type ActivationRead =
76
+ | { readonly status: "missing" }
77
+ | { readonly status: "valid"; readonly record: ActivationRecordV1; readonly identity: FileIdentity }
78
+ | { readonly status: "malformed"; readonly reason: string }
79
+ | { readonly status: "foreign"; readonly reason: string };
80
+
81
+ /** Public snapshot API shared by callers (update-cli/doctor) to capture original target/staging identities. */
82
+ export async function snapshotRegularFile(
83
+ filePath: string,
84
+ ): Promise<{ readonly header: Uint8Array; readonly identity: FileIdentity } | undefined> {
85
+ const snapshot = await readExactRegularFile(filePath, 512 * 1024 * 1024, 8);
86
+ return snapshot ? { header: snapshot.bytes, identity: snapshot.identity } : undefined;
87
+ }
88
+
89
+ export async function snapshotDirectory(directoryPath: string): Promise<DirectoryIdentity | undefined> {
90
+ return readDirectoryIdentity(directoryPath);
91
+ }
92
+
93
+ export function sameFileIdentity(a: FileIdentity, b: FileIdentity): boolean {
94
+ return (
95
+ a.dev === b.dev &&
96
+ a.ino === b.ino &&
97
+ a.nlink === b.nlink &&
98
+ a.size === b.size &&
99
+ a.mtimeNs === b.mtimeNs &&
100
+ a.mode === b.mode &&
101
+ a.sha256 === b.sha256 &&
102
+ a.parentDev === b.parentDev &&
103
+ a.parentIno === b.parentIno
104
+ );
105
+ }
106
+
107
+ export function sameDirectoryIdentity(a: DirectoryIdentity | undefined, b: DirectoryIdentity | undefined): boolean {
108
+ return !!a && !!b && a.dev === b.dev && a.ino === b.ino && a.mode === b.mode;
109
+ }
110
+
111
+ export function toNativeIdentity(identity: FileIdentity, quarantineName?: string): NativeExactFileIdentity {
112
+ return {
113
+ dev: BigInt(identity.dev),
114
+ ino: BigInt(identity.ino),
115
+ nlink: BigInt(identity.nlink),
116
+ ...(identity.parentDev !== undefined && identity.parentIno !== undefined
117
+ ? { parentDev: BigInt(identity.parentDev), parentIno: BigInt(identity.parentIno) }
118
+ : {}),
119
+ size: BigInt(identity.size),
120
+ mtimeNs: BigInt(identity.mtimeNs),
121
+ sha256: identity.sha256,
122
+ ...(quarantineName ? { quarantineName } : {}),
123
+ };
124
+ }
125
+
126
+ export function activationRecordPath(targetPath: string): string {
127
+ return `${path.resolve(targetPath)}.activation.v1.json`;
128
+ }
129
+
130
+ /** Deterministic-enough, collision-checked single-component retained backup name for one transaction. */
131
+ export function activationBackupName(targetPath: string, transactionId: string): string {
132
+ return `${path.basename(path.resolve(targetPath))}.activation-backup.${transactionId}`;
133
+ }
134
+
135
+ export function createActivationTransactionId(): string {
136
+ return randomUUID();
137
+ }
138
+
139
+ /** Pure builder for a fresh `staged` activation record; callers still write it via createActivationRecordFile. */
140
+ export function buildActivationRecord(input: {
141
+ readonly targetPath: string;
142
+ readonly targetIdentity?: FileIdentity;
143
+ readonly originalTargetAbsent?: boolean;
144
+ readonly parentIdentity: DirectoryIdentity;
145
+ readonly baselineDigest?: string;
146
+ readonly baselineVersion?: string;
147
+ readonly stagingPath: string;
148
+ readonly stagingIdentity: FileIdentity;
149
+ readonly candidate: {
150
+ readonly digest: string;
151
+ readonly version: string;
152
+ readonly channel?: string;
153
+ readonly ref?: string;
154
+ readonly os?: string;
155
+ readonly arch?: string;
156
+ readonly sidecars?: readonly string[];
157
+ };
158
+ readonly transactionId?: string;
159
+ }): ActivationRecordV1 {
160
+ if (input.originalTargetAbsent ? input.targetIdentity !== undefined : input.targetIdentity === undefined)
161
+ throw new Error("original_target_observation_missing");
162
+ const transactionId = input.transactionId ?? createActivationTransactionId();
163
+ return {
164
+ schemaVersion: 1,
165
+ kind: "gjc-install-activation",
166
+ transactionId,
167
+ targetPath: path.resolve(input.targetPath),
168
+ parentPath: path.dirname(path.resolve(input.targetPath)),
169
+ targetIdentity: input.targetIdentity,
170
+ parentIdentity: input.parentIdentity,
171
+ baseline: { exists: !input.originalTargetAbsent, digest: input.baselineDigest, version: input.baselineVersion },
172
+ candidate: { ...input.candidate, sidecars: input.candidate.sidecars ?? [] },
173
+ stagingPath: input.stagingPath,
174
+ stagingIdentity: input.stagingIdentity,
175
+ backupName: activationBackupName(input.targetPath, transactionId),
176
+ phase: "staged",
177
+ };
178
+ }
179
+
180
+ const READ_LIMIT = 256 * 1024;
181
+
182
+ /**
183
+ * Bounded, no-follow, identity-revalidated read of one regular file. Rejects
184
+ * symlinks, non-regular files, a symlinked/changed parent, and any content
185
+ * or identity drift observed between the open and the final lexical check.
186
+ * Returns `undefined` only for a confirmed-absent path (ENOENT); every other
187
+ * failure — including a torn/changed read — throws, since presence is a
188
+ * distinct fact from disallowed observation.
189
+ */
190
+ async function readExactRegularFile(
191
+ filePath: string,
192
+ byteLimit = READ_LIMIT,
193
+ prefixLimit = byteLimit,
194
+ ): Promise<{ readonly bytes: Uint8Array; readonly identity: FileIdentity } | undefined> {
195
+ const resolved = path.resolve(filePath);
196
+ const parentPath = path.dirname(resolved);
197
+ let handle: fs.FileHandle | undefined;
198
+ try {
199
+ const before = await fs.lstat(resolved, { bigint: true });
200
+ if (before.isSymbolicLink() || !before.isFile()) throw new Error("target_not_regular");
201
+ if (before.size > BigInt(byteLimit)) throw new Error("target_size_limit");
202
+ const parentBefore = await fs.lstat(parentPath, { bigint: true });
203
+ if (parentBefore.isSymbolicLink() || !parentBefore.isDirectory()) throw new Error("parent_not_directory");
204
+ handle = await fs.open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
205
+ const opened = await handle.stat({ bigint: true });
206
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) throw new Error("target_changed");
207
+ const bytes = new Uint8Array(prefixLimit);
208
+ const chunk = new Uint8Array(64 * 1024);
209
+ const digest = createHash("sha256");
210
+ let length = 0;
211
+ while (length <= byteLimit) {
212
+ const result = await handle.read(chunk, 0, Math.min(chunk.length, byteLimit + 1 - length), length);
213
+ if (result.bytesRead === 0) break;
214
+ const content = chunk.subarray(0, result.bytesRead);
215
+ digest.update(content);
216
+ if (length < prefixLimit)
217
+ bytes.set(content.subarray(0, Math.min(content.length, prefixLimit - length)), length);
218
+ length += result.bytesRead;
219
+ }
220
+ if (length > byteLimit) throw new Error("target_size_limit");
221
+ const content = bytes.subarray(0, Math.min(length, prefixLimit));
222
+ const after = await handle.stat({ bigint: true });
223
+ const lexical = await fs.lstat(resolved, { bigint: true });
224
+ const parentAfter = await fs.lstat(parentPath, { bigint: true });
225
+ if (
226
+ lexical.isSymbolicLink() ||
227
+ parentAfter.isSymbolicLink() ||
228
+ !parentAfter.isDirectory() ||
229
+ parentAfter.dev !== parentBefore.dev ||
230
+ parentAfter.ino !== parentBefore.ino ||
231
+ after.dev !== opened.dev ||
232
+ after.ino !== opened.ino ||
233
+ after.mtimeNs !== opened.mtimeNs ||
234
+ after.ctimeNs !== opened.ctimeNs ||
235
+ after.size !== opened.size ||
236
+ after.nlink !== opened.nlink ||
237
+ lexical.dev !== after.dev ||
238
+ lexical.ino !== after.ino ||
239
+ lexical.mtimeNs !== after.mtimeNs ||
240
+ lexical.ctimeNs !== after.ctimeNs
241
+ )
242
+ throw new Error("target_changed");
243
+ const identity: FileIdentity = {
244
+ dev: after.dev.toString(),
245
+ ino: after.ino.toString(),
246
+ nlink: after.nlink.toString(),
247
+ parentDev: parentAfter.dev.toString(),
248
+ parentIno: parentAfter.ino.toString(),
249
+ size: after.size.toString(),
250
+ mtimeNs: after.mtimeNs.toString(),
251
+ mode: Number(after.mode),
252
+ sha256: digest.digest("hex"),
253
+ };
254
+ return { bytes: content, identity };
255
+ } catch (error) {
256
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
257
+ throw error;
258
+ } finally {
259
+ await handle?.close();
260
+ }
261
+ }
262
+
263
+ /** Bounded, no-follow directory identity. Rejects a symlinked or non-directory path. */
264
+ async function readDirectoryIdentity(directoryPath: string): Promise<DirectoryIdentity | undefined> {
265
+ try {
266
+ const stat = await fs.lstat(path.resolve(directoryPath), { bigint: true });
267
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("parent_not_directory");
268
+ return { dev: stat.dev.toString(), ino: stat.ino.toString(), mode: Number(stat.mode) };
269
+ } catch (error) {
270
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
271
+ throw error;
272
+ }
273
+ }
274
+
275
+ function validFileIdentity(value: unknown): value is FileIdentity {
276
+ if (!value || typeof value !== "object") return false;
277
+ const v = value as Partial<FileIdentity>;
278
+ return (
279
+ typeof v.dev === "string" &&
280
+ typeof v.ino === "string" &&
281
+ typeof v.nlink === "string" &&
282
+ typeof v.size === "string" &&
283
+ typeof v.mtimeNs === "string" &&
284
+ typeof v.mode === "number" &&
285
+ typeof v.sha256 === "string" &&
286
+ /^[a-f0-9]{64}$/.test(v.sha256) &&
287
+ typeof v.parentDev === "string" &&
288
+ typeof v.parentIno === "string" &&
289
+ [v.dev, v.ino, v.nlink, v.size, v.parentDev, v.parentIno].every(
290
+ value => /^(?:0|[1-9]\d{0,19})$/.test(value) && BigInt(value) <= 18446744073709551615n,
291
+ ) &&
292
+ /^-?(?:0|[1-9]\d{0,18})$/.test(v.mtimeNs) &&
293
+ Number.isInteger(v.mode) &&
294
+ v.mode >= 0 &&
295
+ v.mode <= 0xffff &&
296
+ v.nlink === "1"
297
+ );
298
+ }
299
+
300
+ function validDirectoryIdentity(value: unknown): value is DirectoryIdentity {
301
+ if (!value || typeof value !== "object") return false;
302
+ const v = value as Partial<DirectoryIdentity>;
303
+ return (
304
+ typeof v.dev === "string" &&
305
+ typeof v.ino === "string" &&
306
+ [v.dev, v.ino].every(value => /^(?:0|[1-9]\d{0,19})$/.test(value) && BigInt(value) <= 18446744073709551615n) &&
307
+ typeof v.mode === "number" &&
308
+ Number.isInteger(v.mode) &&
309
+ v.mode >= 0 &&
310
+ v.mode <= 0xffff
311
+ );
312
+ }
313
+
314
+ const PHASES = new Set<ActivationPhase>([
315
+ "staged",
316
+ "published",
317
+ "verified",
318
+ "pending_activation",
319
+ "rolled_back",
320
+ "uncertain",
321
+ ]);
322
+ const BACKUP_NAME = /^[^\\/]{1,255}$/;
323
+
324
+ function validRecord(value: unknown, targetPath: string): value is ActivationRecordV1 {
325
+ if (!value || typeof value !== "object") return false;
326
+ const r = value as Partial<ActivationRecordV1>;
327
+ if (
328
+ r.schemaVersion !== 1 ||
329
+ r.kind !== "gjc-install-activation" ||
330
+ typeof r.transactionId !== "string" ||
331
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(r.transactionId) ||
332
+ typeof r.targetPath !== "string" ||
333
+ typeof r.parentPath !== "string" ||
334
+ typeof r.stagingPath !== "string" ||
335
+ typeof r.backupName !== "string" ||
336
+ !BACKUP_NAME.test(r.backupName) ||
337
+ r.backupName.includes("\0") ||
338
+ r.backupName === "." ||
339
+ r.backupName === ".." ||
340
+ typeof r.phase !== "string" ||
341
+ !PHASES.has(r.phase as ActivationPhase)
342
+ )
343
+ return false;
344
+ if (!r.baseline || typeof r.baseline !== "object" || typeof r.baseline.exists !== "boolean") return false;
345
+ if (r.baseline.exists && !r.targetIdentity) return false;
346
+ if (
347
+ !r.candidate ||
348
+ typeof r.candidate !== "object" ||
349
+ typeof r.candidate.digest !== "string" ||
350
+ !/^[a-f0-9]{64}$/.test(r.candidate.digest) ||
351
+ typeof r.candidate.version !== "string" ||
352
+ !Array.isArray(r.candidate.sidecars)
353
+ )
354
+ return false;
355
+ if (r.targetIdentity !== undefined && !validFileIdentity(r.targetIdentity)) return false;
356
+ if (r.backupIdentity !== undefined && !validFileIdentity(r.backupIdentity)) return false;
357
+ if (r.stagingIdentity !== undefined && !validFileIdentity(r.stagingIdentity)) return false;
358
+ if (r.parentIdentity !== undefined && !validDirectoryIdentity(r.parentIdentity)) return false;
359
+ // Target-adjacent authority: the record must literally describe this exact
360
+ // target and its immediate parent, resolved lexically, never inferred from
361
+ // staging/backup basename prefixes.
362
+ const resolvedTarget = path.resolve(targetPath);
363
+ if (path.resolve(r.targetPath) !== resolvedTarget) return false;
364
+ if (path.resolve(r.parentPath) !== path.dirname(resolvedTarget)) return false;
365
+ if (path.dirname(path.resolve(r.stagingPath)) !== path.dirname(resolvedTarget)) return false;
366
+ return true;
367
+ }
368
+
369
+ /**
370
+ * Read the activation record for `targetPath`, itself opened bounded/
371
+ * no-follow/identity-revalidated. A record that is present but fails schema
372
+ * or path validation is reported distinctly (`malformed`/`foreign`) and is
373
+ * never treated as absent, so a caller cannot silently proceed past a
374
+ * corrupted or substituted record.
375
+ */
376
+ export async function readActivationRecord(targetPath: string): Promise<ActivationRead> {
377
+ const recordPath = activationRecordPath(targetPath);
378
+ const snapshot = await readExactRegularFile(recordPath);
379
+ if (!snapshot) return { status: "missing" };
380
+ let parsed: unknown;
381
+ try {
382
+ parsed = JSON.parse(Buffer.from(snapshot.bytes).toString("utf8"));
383
+ } catch {
384
+ return { status: "malformed", reason: "invalid_json" };
385
+ }
386
+ if (!validRecord(parsed, targetPath)) return { status: "foreign", reason: "schema_or_target_mismatch" };
387
+ return { status: "valid", record: parsed, identity: snapshot.identity };
388
+ }
389
+
390
+ /**
391
+ * Create the activation record with a no-replace write: an existing record
392
+ * (foreign, malformed, or a live transaction) is never overwritten blindly.
393
+ */
394
+ export async function createActivationRecordFile(record: ActivationRecordV1): Promise<FileIdentity> {
395
+ if (!validRecord(record, record.targetPath) || !record.parentIdentity) throw new Error("invalid_activation_record");
396
+ const recordPath = activationRecordPath(record.targetPath);
397
+ const tempPath = `${recordPath}.${record.transactionId}.tmp`;
398
+ const handle = await fs.open(
399
+ tempPath,
400
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
401
+ 0o600,
402
+ );
403
+ try {
404
+ await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
405
+ await handle.sync();
406
+ } finally {
407
+ await handle.close();
408
+ }
409
+ const source = await readExactRegularFile(tempPath);
410
+ if (
411
+ !source ||
412
+ !record.parentIdentity ||
413
+ source.identity.parentDev !== record.parentIdentity.dev ||
414
+ source.identity.parentIno !== record.parentIdentity.ino
415
+ )
416
+ throw new Error("activation_record_parent_changed");
417
+ const published = exactRestore(tempPath, recordPath, toNativeIdentity(source.identity));
418
+ const retainedPlaceholder =
419
+ published.code === "cleanup_pending" &&
420
+ published.retainedPlaceholderPath &&
421
+ !published.retainedUnknownPath &&
422
+ !published.retainedSuccessorPath;
423
+ if (!published.ok && !retainedPlaceholder)
424
+ throw new Error(`activation_record_publication_unverified:${published.code ?? "unknown"}`);
425
+ const observed = await readExactRegularFile(recordPath);
426
+ if (!observed || !sameFileIdentity(source.identity, observed.identity))
427
+ throw new Error("activation_record_identity_changed");
428
+ await syncActivationParent(record.parentPath, record.parentIdentity);
429
+ return source.identity;
430
+ }
431
+
432
+ async function syncActivationParent(parentPath: string, expected: DirectoryIdentity): Promise<void> {
433
+ if (process.platform === "win32") return;
434
+ const parent = await fs.open(
435
+ parentPath,
436
+ constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
437
+ );
438
+ try {
439
+ const stat = await parent.stat({ bigint: true });
440
+ if (stat.dev.toString() !== expected.dev || stat.ino.toString() !== expected.ino)
441
+ throw new Error("activation_parent_changed");
442
+ await parent.sync();
443
+ } finally {
444
+ await parent.close();
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Compare-and-swap the activation record: the caller must supply the exact
450
+ * identity of the record it last read. `exactReplacePath` performs the
451
+ * atomic namespace-exchange CAS; a stale or substituted record is refused
452
+ * rather than overwritten.
453
+ */
454
+ export async function updateActivationRecordFile(
455
+ record: ActivationRecordV1,
456
+ expectedIdentity: FileIdentity,
457
+ ): Promise<FileIdentity> {
458
+ if (!validRecord(record, record.targetPath) || !record.parentIdentity) throw new Error("invalid_activation_record");
459
+ const recordPath = activationRecordPath(record.targetPath);
460
+ const tempPath = `${recordPath}.${record.transactionId}.${randomUUID()}.tmp`;
461
+ const handle = await fs.open(
462
+ tempPath,
463
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
464
+ 0o600,
465
+ );
466
+ try {
467
+ await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
468
+ await handle.sync();
469
+ } finally {
470
+ await handle.close();
471
+ }
472
+ const source = await readExactRegularFile(tempPath);
473
+ if (
474
+ !source ||
475
+ source.identity.parentDev !== expectedIdentity.parentDev ||
476
+ source.identity.parentIno !== expectedIdentity.parentIno ||
477
+ source.identity.parentDev !== record.parentIdentity.dev ||
478
+ source.identity.parentIno !== record.parentIdentity.ino
479
+ )
480
+ throw new Error("activation_record_parent_changed");
481
+ const replaced = exactReplacePath(
482
+ tempPath,
483
+ recordPath,
484
+ toNativeIdentity(source.identity),
485
+ toNativeIdentity(expectedIdentity),
486
+ );
487
+ if (!replaced.ok) throw new Error("activation_record_compare_and_swap_failed");
488
+ return source.identity;
489
+ }
490
+
491
+ export type ReconcileStatus =
492
+ | "missing"
493
+ | "verified"
494
+ | "reconciled"
495
+ | "applied_unverified"
496
+ | "rolled_back"
497
+ | "pending_activation"
498
+ | "conflict"
499
+ | "malformed"
500
+ | "foreign";
501
+
502
+ export interface ReconcileResult {
503
+ readonly status: ReconcileStatus;
504
+ readonly record?: ActivationRecordV1;
505
+ readonly recordIdentity?: FileIdentity;
506
+ readonly reason?: string;
507
+ /** Present only for a pre-mutation Windows sharing violation on the destination open. */
508
+ readonly windowsErrorCode?: string;
509
+ }
510
+
511
+ /**
512
+ * Reconcile the activation record for `targetPath` against durable state.
513
+ *
514
+ * `staged`/`pending_activation` phases are promoted onto the live target via
515
+ * `exactReplaceRetained` only when `allowPromotion` is set and every
516
+ * identity (staging candidate, current target, parent) still matches what
517
+ * the record captured; the previous target is retired intact to the
518
+ * record's `backupName`, never unlinked or blind-renamed. `published`
519
+ * records are re-verified and advanced to `verified` once the target
520
+ * digest/identity matches the candidate. `verified` records are read-only
521
+ * re-confirmations. Anything else (target/parent drift, missing staged
522
+ * candidate, an already-occupied backup name, a pre-mutation Windows
523
+ * sharing violation) is reported as `conflict` with the exact reason and
524
+ * never silently promoted.
525
+ */
526
+ export async function reconcileActivationRecord(
527
+ targetPath: string,
528
+ options: { readonly allowPromotion?: boolean; readonly expectedRecordIdentity?: FileIdentity } = {},
529
+ ): Promise<ReconcileResult> {
530
+ const read = await readActivationRecord(targetPath);
531
+ if (read.status === "missing") return { status: "missing" };
532
+ if (read.status === "malformed" || read.status === "foreign") return { status: read.status, reason: read.reason };
533
+ const { record, identity: recordIdentity } = read;
534
+ if (options.expectedRecordIdentity && !sameFileIdentity(options.expectedRecordIdentity, recordIdentity))
535
+ return { status: "conflict", reason: "activation_record_changed" };
536
+
537
+ const parentIdentity = await readDirectoryIdentity(record.parentPath);
538
+ if (record.parentIdentity && !sameDirectoryIdentity(record.parentIdentity, parentIdentity))
539
+ return { status: "conflict", record, reason: "parent_changed" };
540
+
541
+ if (record.phase === "rolled_back") {
542
+ const current = await snapshotRegularFile(targetPath);
543
+ if (!record.baseline.exists)
544
+ return current
545
+ ? { status: "conflict", record, reason: "rollback_identity_changed" }
546
+ : { status: "rolled_back", record, recordIdentity };
547
+ return current &&
548
+ record.targetIdentity &&
549
+ record.backupIdentity &&
550
+ sameFileIdentity(current.identity, record.targetIdentity) &&
551
+ current.identity.sha256 === record.baseline.digest
552
+ ? { status: "rolled_back", record, recordIdentity }
553
+ : { status: "conflict", record, reason: "rollback_identity_changed" };
554
+ }
555
+ if (record.phase === "verified") {
556
+ const current = await snapshotRegularFile(targetPath);
557
+ if (!current) return { status: "conflict", record, reason: "target_missing" };
558
+ if (
559
+ !record.targetIdentity ||
560
+ !sameFileIdentity(record.targetIdentity, current.identity) ||
561
+ current.identity.sha256 !== record.candidate.digest
562
+ )
563
+ return { status: "conflict", record, reason: "target_digest_mismatch" };
564
+ return { status: "verified", record, recordIdentity };
565
+ }
566
+
567
+ if (record.phase === "published") {
568
+ const current = await snapshotRegularFile(targetPath);
569
+ if (!current) return { status: "conflict", record, reason: "target_missing" };
570
+ if (current.identity.sha256 !== record.candidate.digest)
571
+ return { status: "conflict", record, reason: "target_digest_mismatch" };
572
+ if (!record.targetIdentity || !sameFileIdentity(record.targetIdentity, current.identity))
573
+ return { status: "conflict", record, reason: "target_changed" };
574
+ return { status: "applied_unverified", record, recordIdentity, reason: "execution_postcheck_required" };
575
+ }
576
+
577
+ if (record.phase === "staged" || record.phase === "pending_activation") {
578
+ if (!options.allowPromotion) return { status: "pending_activation", record, reason: "activation_pending" };
579
+ if ((record.baseline.exists && !record.targetIdentity) || !record.stagingIdentity || !record.parentIdentity)
580
+ return { status: "conflict", record, reason: "original_snapshot_incomplete" };
581
+ const stagingCurrent = await snapshotRegularFile(record.stagingPath);
582
+ if (!stagingCurrent) return { status: "pending_activation", record, reason: "candidate_missing" };
583
+ if (!sameFileIdentity(record.stagingIdentity, stagingCurrent.identity))
584
+ return { status: "conflict", record, reason: "candidate_changed" };
585
+ if (stagingCurrent.identity.sha256 !== record.candidate.digest)
586
+ return { status: "conflict", record, reason: "candidate_digest_mismatch" };
587
+ const targetCurrent = await snapshotRegularFile(targetPath);
588
+ if (
589
+ record.baseline.exists &&
590
+ (!targetCurrent || !record.targetIdentity || !sameFileIdentity(record.targetIdentity, targetCurrent.identity))
591
+ )
592
+ return { status: "conflict", record, reason: "target_changed" };
593
+ if (!record.baseline.exists && targetCurrent) return { status: "conflict", record, reason: "target_appeared" };
594
+ const result =
595
+ record.baseline.exists && record.targetIdentity
596
+ ? exactReplaceRetained(
597
+ record.stagingPath,
598
+ targetPath,
599
+ record.backupName,
600
+ toNativeIdentity(record.stagingIdentity),
601
+ toNativeIdentity(record.targetIdentity),
602
+ )
603
+ : exactRestore(record.stagingPath, targetPath, toNativeIdentity(record.stagingIdentity));
604
+ const retainedCreationPlaceholder =
605
+ !record.baseline.exists &&
606
+ result.code === "cleanup_pending" &&
607
+ result.retainedPlaceholderPath &&
608
+ !result.retainedUnknownPath &&
609
+ !result.retainedSuccessorPath;
610
+ if (!result.ok && !retainedCreationPlaceholder) {
611
+ if (
612
+ process.platform === "win32" &&
613
+ record.targetIdentity &&
614
+ result.code === "sharing_violation" &&
615
+ result.windowsErrorCode &&
616
+ !result.detachedPath &&
617
+ !result.retainedSuccessorPath &&
618
+ !result.retainedUnknownPath &&
619
+ !result.retainedPlaceholderPath
620
+ ) {
621
+ const unchangedTarget = await snapshotRegularFile(targetPath);
622
+ const unchangedStaging = await snapshotRegularFile(record.stagingPath);
623
+ if (
624
+ unchangedTarget &&
625
+ unchangedStaging &&
626
+ sameFileIdentity(record.targetIdentity, unchangedTarget.identity) &&
627
+ sameFileIdentity(record.stagingIdentity, unchangedStaging.identity)
628
+ ) {
629
+ const pending: ActivationRecordV1 = { ...record, phase: "pending_activation" };
630
+ const pendingIdentity = await updateActivationRecordFile(pending, recordIdentity);
631
+ return {
632
+ status: "pending_activation",
633
+ record: pending,
634
+ recordIdentity: pendingIdentity,
635
+ reason: "sharing_violation",
636
+ windowsErrorCode: result.windowsErrorCode,
637
+ };
638
+ }
639
+ }
640
+ return {
641
+ status: "conflict",
642
+ record,
643
+ reason: result.code ?? "activation_promotion_failed",
644
+ ...(result.windowsErrorCode ? { windowsErrorCode: result.windowsErrorCode } : {}),
645
+ };
646
+ }
647
+ const promoted = await snapshotRegularFile(targetPath);
648
+ if (
649
+ !promoted ||
650
+ promoted.identity.sha256 !== record.candidate.digest ||
651
+ !sameFileIdentity(record.stagingIdentity, promoted.identity)
652
+ )
653
+ return { status: "conflict", record, reason: "post_promotion_digest_mismatch" };
654
+ await syncActivationParent(record.parentPath, record.parentIdentity);
655
+ const publishedRecord: ActivationRecordV1 = {
656
+ ...record,
657
+ backupIdentity: record.targetIdentity,
658
+ targetIdentity: promoted.identity,
659
+ phase: "published",
660
+ };
661
+ const publishedIdentity = await updateActivationRecordFile(publishedRecord, recordIdentity);
662
+ return { status: "reconciled", record: publishedRecord, recordIdentity: publishedIdentity };
663
+ }
664
+
665
+ return { status: "conflict", record, reason: "uncertain_phase_requires_manual_review" };
666
+ }