@ybotok/loam 0.2.0-alpha.7 → 0.2.0-alpha.9

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 (648) hide show
  1. package/CHANGELOG.md +1315 -1
  2. package/COMPARISON.md +91 -3
  3. package/CONTRIBUTING.md +37 -0
  4. package/MIGRATING-from-OpenSpec.md +40 -22
  5. package/QUICKSTART.md +181 -9
  6. package/README.md +256 -57
  7. package/ROADMAP.md +83 -7
  8. package/SCHEMA.md +569 -98
  9. package/WORKFLOW.md +272 -62
  10. package/dist/cli.js +26 -8
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/adopt/adopt.js +5 -10
  13. package/dist/commands/adopt/adopt.js.map +1 -1
  14. package/dist/commands/archive/archive.js +15 -18
  15. package/dist/commands/archive/archive.js.map +1 -1
  16. package/dist/commands/archive/plan/contracts/asyncapi.js +27 -41
  17. package/dist/commands/archive/plan/contracts/asyncapi.js.map +1 -1
  18. package/dist/commands/archive/plan/contracts/consumers.js +56 -0
  19. package/dist/commands/archive/plan/contracts/consumers.js.map +1 -0
  20. package/dist/commands/archive/plan/contracts/openapi.js +25 -42
  21. package/dist/commands/archive/plan/contracts/openapi.js.map +1 -1
  22. package/dist/commands/archive/plan/contracts/verdict-lines.js +41 -0
  23. package/dist/commands/archive/plan/contracts/verdict-lines.js.map +1 -0
  24. package/dist/commands/archive/plan/gate.js +3 -7
  25. package/dist/commands/archive/plan/gate.js.map +1 -1
  26. package/dist/commands/archive/plan/landscape.js +4 -4
  27. package/dist/commands/archive/plan/landscape.js.map +1 -1
  28. package/dist/commands/archive/plan/refusal.js +8 -18
  29. package/dist/commands/archive/plan/refusal.js.map +1 -1
  30. package/dist/commands/archive/plan/specs.js +134 -25
  31. package/dist/commands/archive/plan/specs.js.map +1 -1
  32. package/dist/commands/archive/plan/state.js +1 -0
  33. package/dist/commands/archive/plan/state.js.map +1 -1
  34. package/dist/commands/archive/run.js +33 -11
  35. package/dist/commands/archive/run.js.map +1 -1
  36. package/dist/commands/archive/shipped.js +49 -0
  37. package/dist/commands/archive/shipped.js.map +1 -0
  38. package/dist/commands/context/context.js +4 -7
  39. package/dist/commands/context/context.js.map +1 -1
  40. package/dist/commands/context/print.js +2 -2
  41. package/dist/commands/context/print.js.map +1 -1
  42. package/dist/commands/delta/delta.js +44 -12
  43. package/dist/commands/delta/delta.js.map +1 -1
  44. package/dist/commands/delta/print.js +1 -1
  45. package/dist/commands/delta/print.js.map +1 -1
  46. package/dist/commands/dependencies.js +3 -7
  47. package/dist/commands/dependencies.js.map +1 -1
  48. package/dist/commands/diff/diff.js +20 -27
  49. package/dist/commands/diff/diff.js.map +1 -1
  50. package/dist/commands/explore.js +4 -8
  51. package/dist/commands/explore.js.map +1 -1
  52. package/dist/commands/gate/gate.js +45 -8
  53. package/dist/commands/gate/gate.js.map +1 -1
  54. package/dist/commands/gate/print.js +15 -3
  55. package/dist/commands/gate/print.js.map +1 -1
  56. package/dist/commands/gherkin/commit.js +13 -54
  57. package/dist/commands/gherkin/commit.js.map +1 -1
  58. package/dist/commands/gherkin/gherkin.js +36 -20
  59. package/dist/commands/gherkin/gherkin.js.map +1 -1
  60. package/dist/commands/gherkin/reconcile.js +10 -13
  61. package/dist/commands/gherkin/reconcile.js.map +1 -1
  62. package/dist/commands/init/first-hour.js +44 -0
  63. package/dist/commands/init/first-hour.js.map +1 -1
  64. package/dist/commands/init/init.js +18 -22
  65. package/dist/commands/init/init.js.map +1 -1
  66. package/dist/commands/list/campaign/campaign.js +4 -6
  67. package/dist/commands/list/campaign/campaign.js.map +1 -1
  68. package/dist/commands/list/json.js +0 -4
  69. package/dist/commands/list/json.js.map +1 -1
  70. package/dist/commands/list/list.js +38 -23
  71. package/dist/commands/list/list.js.map +1 -1
  72. package/dist/commands/list/print.js +38 -6
  73. package/dist/commands/list/print.js.map +1 -1
  74. package/dist/commands/list/suites/suites.js +140 -0
  75. package/dist/commands/list/suites/suites.js.map +1 -0
  76. package/dist/commands/list/views.js +28 -33
  77. package/dist/commands/list/views.js.map +1 -1
  78. package/dist/commands/mcp/dispatch.js +91 -36
  79. package/dist/commands/mcp/dispatch.js.map +1 -1
  80. package/dist/commands/mcp/mcp.js +56 -15
  81. package/dist/commands/mcp/mcp.js.map +1 -1
  82. package/dist/commands/mcp/serve.js.map +1 -1
  83. package/dist/commands/migrate-openspec/materialize/active.js +27 -22
  84. package/dist/commands/migrate-openspec/materialize/active.js.map +1 -1
  85. package/dist/commands/migrate-openspec/materialize/stage.js +4 -3
  86. package/dist/commands/migrate-openspec/materialize/stage.js.map +1 -1
  87. package/dist/commands/migrate-openspec/materialize/target.js +2 -2
  88. package/dist/commands/migrate-openspec/materialize/target.js.map +1 -1
  89. package/dist/commands/migrate-openspec/openspec/decisions.js +9 -14
  90. package/dist/commands/migrate-openspec/openspec/decisions.js.map +1 -1
  91. package/dist/commands/migrate-openspec/openspec/error.js +7 -8
  92. package/dist/commands/migrate-openspec/openspec/error.js.map +1 -1
  93. package/dist/commands/migrate-openspec/openspec/mapping.js +1 -9
  94. package/dist/commands/migrate-openspec/openspec/mapping.js.map +1 -1
  95. package/dist/commands/migrate-openspec/openspec/paths.js +3 -6
  96. package/dist/commands/migrate-openspec/openspec/paths.js.map +1 -1
  97. package/dist/commands/new/capability-template.js +118 -0
  98. package/dist/commands/new/capability-template.js.map +1 -0
  99. package/dist/commands/new/commit.js +8 -22
  100. package/dist/commands/new/commit.js.map +1 -1
  101. package/dist/commands/new/modifies.js +206 -0
  102. package/dist/commands/new/modifies.js.map +1 -0
  103. package/dist/commands/new/new.js +59 -14
  104. package/dist/commands/new/new.js.map +1 -1
  105. package/dist/commands/new/templates.js +48 -124
  106. package/dist/commands/new/templates.js.map +1 -1
  107. package/dist/commands/open.js +3 -7
  108. package/dist/commands/open.js.map +1 -1
  109. package/dist/commands/policy/gate.js +27 -0
  110. package/dist/commands/policy/gate.js.map +1 -1
  111. package/dist/commands/rebase/plan.js +2 -2
  112. package/dist/commands/rebase/plan.js.map +1 -1
  113. package/dist/commands/rebase/rebase.js +60 -35
  114. package/dist/commands/rebase/rebase.js.map +1 -1
  115. package/dist/commands/seed/commit.js +21 -25
  116. package/dist/commands/seed/commit.js.map +1 -1
  117. package/dist/commands/seed/seed.js +6 -9
  118. package/dist/commands/seed/seed.js.map +1 -1
  119. package/dist/commands/show/show.js +3 -7
  120. package/dist/commands/show/show.js.map +1 -1
  121. package/dist/commands/status/print.js +13 -4
  122. package/dist/commands/status/print.js.map +1 -1
  123. package/dist/commands/status/status.js +3 -7
  124. package/dist/commands/status/status.js.map +1 -1
  125. package/dist/commands/steps/steps.js +3 -7
  126. package/dist/commands/steps/steps.js.map +1 -1
  127. package/dist/commands/subsystem/edit.js +3 -5
  128. package/dist/commands/subsystem/edit.js.map +1 -1
  129. package/dist/commands/subsystem/move.js +5 -8
  130. package/dist/commands/subsystem/move.js.map +1 -1
  131. package/dist/commands/subsystem/query.js +2 -4
  132. package/dist/commands/subsystem/query.js.map +1 -1
  133. package/dist/commands/subsystem/subsystem.js +4 -7
  134. package/dist/commands/subsystem/subsystem.js.map +1 -1
  135. package/dist/commands/subsystem/sync.js +14 -41
  136. package/dist/commands/subsystem/sync.js.map +1 -1
  137. package/dist/commands/subsystem/txn/txn.js +17 -38
  138. package/dist/commands/subsystem/txn/txn.js.map +1 -1
  139. package/dist/commands/subsystem/txn/views.js +32 -5
  140. package/dist/commands/subsystem/txn/views.js.map +1 -1
  141. package/dist/commands/tasks/render.js +116 -0
  142. package/dist/commands/tasks/render.js.map +1 -0
  143. package/dist/commands/tasks/tasks.js +113 -0
  144. package/dist/commands/tasks/tasks.js.map +1 -0
  145. package/dist/commands/unarchive/manifest.js +6 -9
  146. package/dist/commands/unarchive/manifest.js.map +1 -1
  147. package/dist/commands/unarchive/restore.js +17 -28
  148. package/dist/commands/unarchive/restore.js.map +1 -1
  149. package/dist/commands/unarchive/unarchive.js +22 -30
  150. package/dist/commands/unarchive/unarchive.js.map +1 -1
  151. package/dist/commands/validate/arch-coverage.js +6 -15
  152. package/dist/commands/validate/arch-coverage.js.map +1 -1
  153. package/dist/commands/validate/checks/fleet-shape.js +4 -49
  154. package/dist/commands/validate/checks/fleet-shape.js.map +1 -1
  155. package/dist/commands/validate/checks/requirements.js +18 -8
  156. package/dist/commands/validate/checks/requirements.js.map +1 -1
  157. package/dist/commands/validate/checks/sources.js +6 -2
  158. package/dist/commands/validate/checks/sources.js.map +1 -1
  159. package/dist/commands/validate/feature.js +23 -19
  160. package/dist/commands/validate/feature.js.map +1 -1
  161. package/dist/commands/validate/fleet/landscape.js +23 -23
  162. package/dist/commands/validate/fleet/landscape.js.map +1 -1
  163. package/dist/commands/validate/fleet/map/attest.js +6 -10
  164. package/dist/commands/validate/fleet/map/attest.js.map +1 -1
  165. package/dist/commands/validate/fleet/map/bindings.js +1 -3
  166. package/dist/commands/validate/fleet/map/bindings.js.map +1 -1
  167. package/dist/commands/validate/fleet/map/usecases.js +5 -11
  168. package/dist/commands/validate/fleet/map/usecases.js.map +1 -1
  169. package/dist/commands/validate/fleet/obligations.js +1 -5
  170. package/dist/commands/validate/fleet/obligations.js.map +1 -1
  171. package/dist/commands/validate/fleet/scorecard/print.js +5 -1
  172. package/dist/commands/validate/fleet/scorecard/print.js.map +1 -1
  173. package/dist/commands/validate/fleet/scorecard/scorecard.js +17 -11
  174. package/dist/commands/validate/fleet/scorecard/scorecard.js.map +1 -1
  175. package/dist/commands/validate/fleet/usecases/usecases.js +6 -4
  176. package/dist/commands/validate/fleet/usecases/usecases.js.map +1 -1
  177. package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -1
  178. package/dist/commands/validate/fleet/views/root-documents.js +8 -28
  179. package/dist/commands/validate/fleet/views/root-documents.js.map +1 -1
  180. package/dist/commands/validate/links/corpus.js +1 -4
  181. package/dist/commands/validate/links/corpus.js.map +1 -1
  182. package/dist/commands/validate/links/glossary.js.map +1 -1
  183. package/dist/commands/validate/report.js +5 -4
  184. package/dist/commands/validate/report.js.map +1 -1
  185. package/dist/commands/validate/scope.js +113 -0
  186. package/dist/commands/validate/scope.js.map +1 -0
  187. package/dist/commands/validate/service/api.js +16 -104
  188. package/dist/commands/validate/service/api.js.map +1 -1
  189. package/dist/commands/validate/service/contracts/generated.js +134 -0
  190. package/dist/commands/validate/service/contracts/generated.js.map +1 -0
  191. package/dist/commands/validate/service/events/events.js +1 -1
  192. package/dist/commands/validate/service/events/events.js.map +1 -1
  193. package/dist/commands/validate/service/events/message-producers.js +1 -1
  194. package/dist/commands/validate/service/events/message-producers.js.map +1 -1
  195. package/dist/commands/validate/service/evidence-pins.js +56 -10
  196. package/dist/commands/validate/service/evidence-pins.js.map +1 -1
  197. package/dist/commands/validate/service/service.js +16 -26
  198. package/dist/commands/validate/service/service.js.map +1 -1
  199. package/dist/commands/validate/service/specs.js +31 -38
  200. package/dist/commands/validate/service/specs.js.map +1 -1
  201. package/dist/commands/validate/service/spine.js +8 -11
  202. package/dist/commands/validate/service/spine.js.map +1 -1
  203. package/dist/commands/validate/service/usecases/usecases.js +3 -3
  204. package/dist/commands/validate/service/usecases/usecases.js.map +1 -1
  205. package/dist/commands/validate/validate.js +44 -26
  206. package/dist/commands/validate/validate.js.map +1 -1
  207. package/dist/commands/verify/cross/diff.js +2 -6
  208. package/dist/commands/verify/cross/diff.js.map +1 -1
  209. package/dist/commands/verify/evidence/contract.js +1 -1
  210. package/dist/commands/verify/evidence/contract.js.map +1 -1
  211. package/dist/commands/verify/evidence/read.js +12 -3
  212. package/dist/commands/verify/evidence/read.js.map +1 -1
  213. package/dist/commands/verify/frozen.js +45 -23
  214. package/dist/commands/verify/frozen.js.map +1 -1
  215. package/dist/commands/verify/record.js +69 -17
  216. package/dist/commands/verify/record.js.map +1 -1
  217. package/dist/commands/verify/report.js +27 -44
  218. package/dist/commands/verify/report.js.map +1 -1
  219. package/dist/commands/verify/results.js +112 -30
  220. package/dist/commands/verify/results.js.map +1 -1
  221. package/dist/commands/verify/verify.js +54 -51
  222. package/dist/commands/verify/verify.js.map +1 -1
  223. package/dist/commands/vouch/pack/pack.js +2 -2
  224. package/dist/commands/vouch/pack/pack.js.map +1 -1
  225. package/dist/commands/vouch/pack/print.js +1 -6
  226. package/dist/commands/vouch/pack/print.js.map +1 -1
  227. package/dist/commands/vouch/pack/sections.js +1 -2
  228. package/dist/commands/vouch/pack/sections.js.map +1 -1
  229. package/dist/commands/vouch/run.js +26 -81
  230. package/dist/commands/vouch/run.js.map +1 -1
  231. package/dist/commands/vouch/sample/plan.js +20 -34
  232. package/dist/commands/vouch/sample/plan.js.map +1 -1
  233. package/dist/commands/vouch/vet/verify.js +39 -7
  234. package/dist/commands/vouch/vet/verify.js.map +1 -1
  235. package/dist/commands/vouch/vouch.js +10 -17
  236. package/dist/commands/vouch/vouch.js.map +1 -1
  237. package/dist/core/agent/agents-md/command-map.js +56 -101
  238. package/dist/core/agent/agents-md/command-map.js.map +1 -1
  239. package/dist/core/agent/agents-md/map/explain.js +4 -3
  240. package/dist/core/agent/agents-md/map/explain.js.map +1 -1
  241. package/dist/core/agent/agents-md/map/lenses/context.js +4 -3
  242. package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -1
  243. package/dist/core/agent/agents-md/map/lenses/diff.js +33 -20
  244. package/dist/core/agent/agents-md/map/lenses/diff.js.map +1 -1
  245. package/dist/core/agent/agents-md/map/lenses/gate.js +13 -5
  246. package/dist/core/agent/agents-md/map/lenses/gate.js.map +1 -1
  247. package/dist/core/agent/agents-md/map/mcp.js +10 -7
  248. package/dist/core/agent/agents-md/map/mcp.js.map +1 -1
  249. package/dist/core/agent/agents-md/map/status.js +9 -3
  250. package/dist/core/agent/agents-md/map/status.js.map +1 -1
  251. package/dist/core/agent/agents-md/map/subsystem.js +4 -3
  252. package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
  253. package/dist/core/agent/agents-md/map/usecases.js +24 -6
  254. package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
  255. package/dist/core/agent/agents-md/refusals.js +32 -18
  256. package/dist/core/agent/agents-md/refusals.js.map +1 -1
  257. package/dist/core/agent/agents-md.js +9 -3
  258. package/dist/core/agent/agents-md.js.map +1 -1
  259. package/dist/core/agent/agents-stamp.js +10 -0
  260. package/dist/core/agent/agents-stamp.js.map +1 -1
  261. package/dist/core/agent/scaffold.js +10 -15
  262. package/dist/core/agent/scaffold.js.map +1 -1
  263. package/dist/core/agent/tools/dialects.js +1 -0
  264. package/dist/core/agent/tools/dialects.js.map +1 -1
  265. package/dist/core/agent/tools/registry.js +41 -76
  266. package/dist/core/agent/tools/registry.js.map +1 -1
  267. package/dist/core/agent/workflows/adopt.js +5 -3
  268. package/dist/core/agent/workflows/adopt.js.map +1 -1
  269. package/dist/core/agent/workflows/check.js +20 -16
  270. package/dist/core/agent/workflows/check.js.map +1 -1
  271. package/dist/core/agent/workflows/closing.js +54 -21
  272. package/dist/core/agent/workflows/closing.js.map +1 -1
  273. package/dist/core/agent/workflows/feature.js +79 -11
  274. package/dist/core/agent/workflows/feature.js.map +1 -1
  275. package/dist/core/agent/workflows/implement.js +91 -13
  276. package/dist/core/agent/workflows/implement.js.map +1 -1
  277. package/dist/core/agent/workflows/reference/authoring.js +3 -1
  278. package/dist/core/agent/workflows/reference/authoring.js.map +1 -1
  279. package/dist/core/agent/workflows/reference/codes.js +8 -6
  280. package/dist/core/agent/workflows/reference/codes.js.map +1 -1
  281. package/dist/core/agent/workflows/reference/done-check.js +37 -15
  282. package/dist/core/agent/workflows/reference/done-check.js.map +1 -1
  283. package/dist/core/agent/workflows/reference/reference.js.map +1 -1
  284. package/dist/core/agent/workflows/reference/spine.js +50 -18
  285. package/dist/core/agent/workflows/reference/spine.js.map +1 -1
  286. package/dist/core/asyncapi/baseline/plan.js +14 -81
  287. package/dist/core/asyncapi/baseline/plan.js.map +1 -1
  288. package/dist/core/asyncapi/digest.js +30 -25
  289. package/dist/core/asyncapi/digest.js.map +1 -1
  290. package/dist/core/asyncapi/merge/components.js +5 -4
  291. package/dist/core/asyncapi/merge/components.js.map +1 -1
  292. package/dist/core/asyncapi/merge/error.js +7 -2
  293. package/dist/core/asyncapi/merge/error.js.map +1 -1
  294. package/dist/core/asyncapi/merge/markers.js +30 -50
  295. package/dist/core/asyncapi/merge/markers.js.map +1 -1
  296. package/dist/core/asyncapi/merge/merge.js +31 -53
  297. package/dist/core/asyncapi/merge/merge.js.map +1 -1
  298. package/dist/core/asyncapi/merge/pin.js +10 -36
  299. package/dist/core/asyncapi/merge/pin.js.map +1 -1
  300. package/dist/core/asyncapi/model.js.map +1 -1
  301. package/dist/core/asyncapi/read.js +36 -12
  302. package/dist/core/asyncapi/read.js.map +1 -1
  303. package/dist/core/brief/brief.js +6 -18
  304. package/dist/core/brief/brief.js.map +1 -1
  305. package/dist/core/brief/landscape.js +8 -12
  306. package/dist/core/brief/landscape.js.map +1 -1
  307. package/dist/core/brief/map/extend.js +8 -5
  308. package/dist/core/brief/map/extend.js.map +1 -1
  309. package/dist/core/brief/map/owed.js +8 -26
  310. package/dist/core/brief/map/owed.js.map +1 -1
  311. package/dist/core/brief/targets.js +15 -5
  312. package/dist/core/brief/targets.js.map +1 -1
  313. package/dist/core/c4/likec4.js +147 -16
  314. package/dist/core/c4/likec4.js.map +1 -1
  315. package/dist/core/c4/locate/unlocated.js +150 -0
  316. package/dist/core/c4/locate/unlocated.js.map +1 -0
  317. package/dist/core/c4/parsed/values.js +54 -1
  318. package/dist/core/c4/parsed/values.js.map +1 -1
  319. package/dist/core/c4/prefetch/fleet.js +1 -9
  320. package/dist/core/c4/prefetch/fleet.js.map +1 -1
  321. package/dist/core/c4/project/documents.js +31 -6
  322. package/dist/core/c4/project/documents.js.map +1 -1
  323. package/dist/core/c4/project/load.js +6 -17
  324. package/dist/core/c4/project/load.js.map +1 -1
  325. package/dist/core/c4/resolve/external.js +51 -0
  326. package/dist/core/c4/resolve/external.js.map +1 -0
  327. package/dist/core/c4/resolve/service.js +25 -1
  328. package/dist/core/c4/resolve/service.js.map +1 -1
  329. package/dist/core/c4/seed/template.js +15 -1
  330. package/dist/core/c4/seed/template.js.map +1 -1
  331. package/dist/core/c4/service-model/shape.js +2 -22
  332. package/dist/core/c4/service-model/shape.js.map +1 -1
  333. package/dist/core/c4/source-mask.js +288 -4
  334. package/dist/core/c4/source-mask.js.map +1 -1
  335. package/dist/core/c4/source-scan.js +203 -147
  336. package/dist/core/c4/source-scan.js.map +1 -1
  337. package/dist/core/c4/splice/authored-source.js +19 -0
  338. package/dist/core/c4/splice/authored-source.js.map +1 -1
  339. package/dist/core/c4/splice/delta-blocks.js +5 -18
  340. package/dist/core/c4/splice/delta-blocks.js.map +1 -1
  341. package/dist/core/c4/splice/landscape-merge.js +13 -8
  342. package/dist/core/c4/splice/landscape-merge.js.map +1 -1
  343. package/dist/core/c4/splice/model/merge.js +10 -6
  344. package/dist/core/c4/splice/model/merge.js.map +1 -1
  345. package/dist/core/c4/splice/placement.js +108 -18
  346. package/dist/core/c4/splice/placement.js.map +1 -1
  347. package/dist/core/c4/workspace.js +13 -22
  348. package/dist/core/c4/workspace.js.map +1 -1
  349. package/dist/core/capabilities/capabilities.js +6 -6
  350. package/dist/core/capabilities/capabilities.js.map +1 -1
  351. package/dist/core/capabilities/delta/doc.js +7 -10
  352. package/dist/core/capabilities/delta/doc.js.map +1 -1
  353. package/dist/core/capabilities/findings.js +15 -20
  354. package/dist/core/capabilities/findings.js.map +1 -1
  355. package/dist/core/capabilities/realizes/findings.js +12 -19
  356. package/dist/core/capabilities/realizes/findings.js.map +1 -1
  357. package/dist/core/capabilities/realizes/join.js +0 -20
  358. package/dist/core/capabilities/realizes/join.js.map +1 -1
  359. package/dist/core/capabilities/rollup.js +6 -0
  360. package/dist/core/capabilities/rollup.js.map +1 -1
  361. package/dist/core/capabilities/tree.js +6 -28
  362. package/dist/core/capabilities/tree.js.map +1 -1
  363. package/dist/core/capabilities/usecase-join.js +3 -13
  364. package/dist/core/capabilities/usecase-join.js.map +1 -1
  365. package/dist/core/coherence/authoring/scaffold.js +11 -3
  366. package/dist/core/coherence/authoring/scaffold.js.map +1 -1
  367. package/dist/core/coherence/authoring/sentinels.js +2 -1
  368. package/dist/core/coherence/authoring/sentinels.js.map +1 -1
  369. package/dist/core/coherence/coherence.js +30 -16
  370. package/dist/core/coherence/coherence.js.map +1 -1
  371. package/dist/core/coherence/consumed/operations.js +70 -0
  372. package/dist/core/coherence/consumed/operations.js.map +1 -0
  373. package/dist/core/coherence/consumed/refusal.js +38 -0
  374. package/dist/core/coherence/consumed/refusal.js.map +1 -0
  375. package/dist/core/coherence/declared.js +60 -34
  376. package/dist/core/coherence/declared.js.map +1 -1
  377. package/dist/core/coherence/events/declared.js +44 -59
  378. package/dist/core/coherence/events/declared.js.map +1 -1
  379. package/dist/core/coherence/events/events.js +119 -8
  380. package/dist/core/coherence/events/events.js.map +1 -1
  381. package/dist/core/coherence/events/grades.js +31 -8
  382. package/dist/core/coherence/events/grades.js.map +1 -1
  383. package/dist/core/coherence/events/lookups.js +14 -82
  384. package/dist/core/coherence/events/lookups.js.map +1 -1
  385. package/dist/core/coherence/lookups.js +18 -78
  386. package/dist/core/coherence/lookups.js.map +1 -1
  387. package/dist/core/coherence/retired/archived.js +285 -0
  388. package/dist/core/coherence/retired/archived.js.map +1 -0
  389. package/dist/core/coherence/retired/event-pair.js +62 -0
  390. package/dist/core/coherence/retired/event-pair.js.map +1 -0
  391. package/dist/core/coherence/retired/merged.js +79 -0
  392. package/dist/core/coherence/retired/merged.js.map +1 -0
  393. package/dist/core/coherence/retired/retirement.js +49 -0
  394. package/dist/core/coherence/retired/retirement.js.map +1 -0
  395. package/dist/core/coherence/retired/revival/arrivals.js +131 -0
  396. package/dist/core/coherence/retired/revival/arrivals.js.map +1 -0
  397. package/dist/core/coherence/retired/revival/revived.js +297 -0
  398. package/dist/core/coherence/retired/revival/revived.js.map +1 -0
  399. package/dist/core/consumers/after.js +113 -0
  400. package/dist/core/consumers/after.js.map +1 -0
  401. package/dist/core/consumers/consumers.js +296 -0
  402. package/dist/core/consumers/consumers.js.map +1 -0
  403. package/dist/core/consumers/current.js +211 -0
  404. package/dist/core/consumers/current.js.map +1 -0
  405. package/dist/core/consumers/scan.js +46 -0
  406. package/dist/core/consumers/scan.js.map +1 -0
  407. package/dist/core/consumers/state.js +27 -0
  408. package/dist/core/consumers/state.js.map +1 -0
  409. package/dist/core/delta/delta.js +1 -1
  410. package/dist/core/delta/delta.js.map +1 -1
  411. package/dist/core/deployment/delta.js +3 -6
  412. package/dist/core/deployment/delta.js.map +1 -1
  413. package/dist/core/diff/base-git.js +94 -12
  414. package/dist/core/diff/base-git.js.map +1 -1
  415. package/dist/core/diff/base-state.js +28 -16
  416. package/dist/core/diff/base-state.js.map +1 -1
  417. package/dist/core/diff/joins.js +136 -0
  418. package/dist/core/diff/joins.js.map +1 -0
  419. package/dist/core/diff/scope/changed-paths.js +22 -6
  420. package/dist/core/diff/scope/changed-paths.js.map +1 -1
  421. package/dist/core/diff/scope/edges.js +82 -0
  422. package/dist/core/diff/scope/edges.js.map +1 -0
  423. package/dist/core/diff/semantic.js +100 -31
  424. package/dist/core/diff/semantic.js.map +1 -1
  425. package/dist/core/doctor/agents.js +34 -18
  426. package/dist/core/doctor/agents.js.map +1 -1
  427. package/dist/core/doctor/report.js.map +1 -1
  428. package/dist/core/document/apply.js +10 -2
  429. package/dist/core/document/apply.js.map +1 -1
  430. package/dist/core/document/frontmatter.js +55 -0
  431. package/dist/core/document/frontmatter.js.map +1 -1
  432. package/dist/core/document/parse.js +29 -6
  433. package/dist/core/document/parse.js.map +1 -1
  434. package/dist/core/document/scenarios.js +17 -0
  435. package/dist/core/document/scenarios.js.map +1 -1
  436. package/dist/core/document/spec.js +4 -11
  437. package/dist/core/document/spec.js.map +1 -1
  438. package/dist/core/envelope/config.js +10 -0
  439. package/dist/core/envelope/config.js.map +1 -1
  440. package/dist/core/envelope/json.js +34 -0
  441. package/dist/core/envelope/json.js.map +1 -1
  442. package/dist/core/explain/families.js +17 -12
  443. package/dist/core/explain/families.js.map +1 -1
  444. package/dist/core/explain/refusals.js +3 -3
  445. package/dist/core/explain/refusals.js.map +1 -1
  446. package/dist/core/explain/terms.js +18 -9
  447. package/dist/core/explain/terms.js.map +1 -1
  448. package/dist/core/explore/explore.js +2 -7
  449. package/dist/core/explore/explore.js.map +1 -1
  450. package/dist/core/fleet-context.js +34 -95
  451. package/dist/core/fleet-context.js.map +1 -1
  452. package/dist/core/gate/checks.js +5 -2
  453. package/dist/core/gate/checks.js.map +1 -1
  454. package/dist/core/gate/partners.js +4 -2
  455. package/dist/core/gate/partners.js.map +1 -1
  456. package/dist/core/gate/report.js.map +1 -1
  457. package/dist/core/gate/verification.js +75 -12
  458. package/dist/core/gate/verification.js.map +1 -1
  459. package/dist/core/gherkin/stale.js +13 -37
  460. package/dist/core/gherkin/stale.js.map +1 -1
  461. package/dist/core/glossary/tree.js +6 -19
  462. package/dist/core/glossary/tree.js.map +1 -1
  463. package/dist/core/kernel/concurrency.js +19 -0
  464. package/dist/core/kernel/concurrency.js.map +1 -1
  465. package/dist/core/kernel/records.js +11 -0
  466. package/dist/core/kernel/records.js.map +1 -1
  467. package/dist/core/links/backlinks.js +1 -3
  468. package/dist/core/links/backlinks.js.map +1 -1
  469. package/dist/core/links/corpus.js +5 -36
  470. package/dist/core/links/corpus.js.map +1 -1
  471. package/dist/core/mcp/argv.js +101 -0
  472. package/dist/core/mcp/argv.js.map +1 -1
  473. package/dist/core/mcp/author-tools.js +2 -1
  474. package/dist/core/mcp/author-tools.js.map +1 -1
  475. package/dist/core/mcp/protocol.js +2 -2
  476. package/dist/core/mcp/protocol.js.map +1 -1
  477. package/dist/core/mcp/tools.js +43 -33
  478. package/dist/core/mcp/tools.js.map +1 -1
  479. package/dist/core/openapi/baseline/gate.js +53 -12
  480. package/dist/core/openapi/baseline/gate.js.map +1 -1
  481. package/dist/core/openapi/baseline/plan.js +22 -8
  482. package/dist/core/openapi/baseline/plan.js.map +1 -1
  483. package/dist/core/openapi/baseline/record.js +0 -1
  484. package/dist/core/openapi/baseline/record.js.map +1 -1
  485. package/dist/core/openapi/contract-text.js +33 -0
  486. package/dist/core/openapi/contract-text.js.map +1 -0
  487. package/dist/core/openapi/contract-tree.js +56 -0
  488. package/dist/core/openapi/contract-tree.js.map +1 -0
  489. package/dist/core/openapi/depth.js +38 -24
  490. package/dist/core/openapi/depth.js.map +1 -1
  491. package/dist/core/openapi/digest.js +52 -2
  492. package/dist/core/openapi/digest.js.map +1 -1
  493. package/dist/core/openapi/doc.js +12 -4
  494. package/dist/core/openapi/doc.js.map +1 -1
  495. package/dist/core/openapi/merge/components.js +25 -60
  496. package/dist/core/openapi/merge/components.js.map +1 -1
  497. package/dist/core/openapi/merge/error.js +3 -13
  498. package/dist/core/openapi/merge/error.js.map +1 -1
  499. package/dist/core/openapi/merge/markers.js +26 -30
  500. package/dist/core/openapi/merge/markers.js.map +1 -1
  501. package/dist/core/openapi/merge/merge.js +26 -52
  502. package/dist/core/openapi/merge/merge.js.map +1 -1
  503. package/dist/core/openapi/merge/pin.js +15 -35
  504. package/dist/core/openapi/merge/pin.js.map +1 -1
  505. package/dist/core/openspec/decide/verdict.js.map +1 -1
  506. package/dist/core/openspec/inventory.js +4 -5
  507. package/dist/core/openspec/inventory.js.map +1 -1
  508. package/dist/core/openspec/model/mapping.js +30 -28
  509. package/dist/core/openspec/model/mapping.js.map +1 -1
  510. package/dist/core/openspec/model/model.js +12 -7
  511. package/dist/core/openspec/model/model.js.map +1 -1
  512. package/dist/core/openspec/scan/changes.js +10 -4
  513. package/dist/core/openspec/scan/changes.js.map +1 -1
  514. package/dist/core/openspec/scan/config.js +7 -16
  515. package/dist/core/openspec/scan/config.js.map +1 -1
  516. package/dist/core/openspec/scan/shape.js +63 -19
  517. package/dist/core/openspec/scan/shape.js.map +1 -1
  518. package/dist/core/openspec/scan/walk.js +21 -38
  519. package/dist/core/openspec/scan/walk.js.map +1 -1
  520. package/dist/core/pack/living.js +2 -16
  521. package/dist/core/pack/living.js.map +1 -1
  522. package/dist/core/provenance/findings.js +48 -12
  523. package/dist/core/provenance/findings.js.map +1 -1
  524. package/dist/core/provenance/git.js +33 -57
  525. package/dist/core/provenance/git.js.map +1 -1
  526. package/dist/core/provenance/gitq/moves.js +71 -114
  527. package/dist/core/provenance/gitq/moves.js.map +1 -1
  528. package/dist/core/provenance/gitq/vouched-ancestor.js +2 -2
  529. package/dist/core/provenance/gitq/vouched-ancestor.js.map +1 -1
  530. package/dist/core/provenance/sources.js +23 -6
  531. package/dist/core/provenance/sources.js.map +1 -1
  532. package/dist/core/provenance/stamp.js +5 -17
  533. package/dist/core/provenance/stamp.js.map +1 -1
  534. package/dist/core/repo/entries.js.map +1 -1
  535. package/dist/core/repo/repo.js +24 -14
  536. package/dist/core/repo/repo.js.map +1 -1
  537. package/dist/core/repo/tree/find.js +12 -3
  538. package/dist/core/repo/tree/find.js.map +1 -1
  539. package/dist/core/repo/tree/fs.js +27 -1
  540. package/dist/core/repo/tree/fs.js.map +1 -1
  541. package/dist/core/repo/tree/walk.js +8 -30
  542. package/dist/core/repo/tree/walk.js.map +1 -1
  543. package/dist/core/results.js +47 -12
  544. package/dist/core/results.js.map +1 -1
  545. package/dist/core/staging/commit.js +10 -12
  546. package/dist/core/staging/commit.js.map +1 -1
  547. package/dist/core/staging/lock.js +74 -12
  548. package/dist/core/staging/lock.js.map +1 -1
  549. package/dist/core/staging/recovery/intent.js +2 -5
  550. package/dist/core/staging/recovery/intent.js.map +1 -1
  551. package/dist/core/staging/recovery/recover.js +60 -1
  552. package/dist/core/staging/recovery/recover.js.map +1 -1
  553. package/dist/core/staging/snapshot/manifest.js +152 -0
  554. package/dist/core/staging/snapshot/manifest.js.map +1 -0
  555. package/dist/core/staging/{snapshot.js → snapshot/snapshot.js} +10 -34
  556. package/dist/core/staging/snapshot/snapshot.js.map +1 -0
  557. package/dist/core/status/actions/execution.js +11 -0
  558. package/dist/core/status/actions/execution.js.map +1 -1
  559. package/dist/core/status/feature/artifacts.js +2 -2
  560. package/dist/core/status/feature/artifacts.js.map +1 -1
  561. package/dist/core/status/feature/feature.js +5 -11
  562. package/dist/core/status/feature/feature.js.map +1 -1
  563. package/dist/core/status/fleet/fleet.js +6 -2
  564. package/dist/core/status/fleet/fleet.js.map +1 -1
  565. package/dist/core/status/fleet/next.js +26 -3
  566. package/dist/core/status/fleet/next.js.map +1 -1
  567. package/dist/core/status/report.js.map +1 -1
  568. package/dist/core/status/verification.js +30 -15
  569. package/dist/core/status/verification.js.map +1 -1
  570. package/dist/core/tasks/packet.js +174 -0
  571. package/dist/core/tasks/packet.js.map +1 -0
  572. package/dist/core/tasks/record.js +43 -0
  573. package/dist/core/tasks/record.js.map +1 -0
  574. package/dist/core/tasks/sources.js +181 -0
  575. package/dist/core/tasks/sources.js.map +1 -0
  576. package/dist/core/tasks/stamps.js +76 -0
  577. package/dist/core/tasks/stamps.js.map +1 -0
  578. package/dist/core/tasks/unit.js +0 -0
  579. package/dist/core/tasks/unit.js.map +1 -0
  580. package/dist/core/usecases/capability.js +11 -14
  581. package/dist/core/usecases/capability.js.map +1 -1
  582. package/dist/core/usecases/delta/flows.js +5 -7
  583. package/dist/core/usecases/delta/flows.js.map +1 -1
  584. package/dist/core/usecases/fleet.js +178 -31
  585. package/dist/core/usecases/fleet.js.map +1 -1
  586. package/dist/core/usecases/operations.js +5 -4
  587. package/dist/core/usecases/operations.js.map +1 -1
  588. package/dist/core/usecases/service/flows.js +9 -6
  589. package/dist/core/usecases/service/flows.js.map +1 -1
  590. package/dist/core/usecases/suites/participants.js +83 -0
  591. package/dist/core/usecases/suites/participants.js.map +1 -0
  592. package/dist/core/usecases/suites/suites.js +45 -0
  593. package/dist/core/usecases/suites/suites.js.map +1 -0
  594. package/dist/core/verify/answers.js +12 -5
  595. package/dist/core/verify/answers.js.map +1 -1
  596. package/dist/core/verify/build.js +102 -82
  597. package/dist/core/verify/build.js.map +1 -1
  598. package/dist/core/verify/checklist.js +3 -0
  599. package/dist/core/verify/checklist.js.map +1 -1
  600. package/dist/core/verify/location.js +67 -0
  601. package/dist/core/verify/location.js.map +1 -0
  602. package/dist/core/verify/pins/lint.js +3 -2
  603. package/dist/core/verify/pins/lint.js.map +1 -1
  604. package/dist/core/verify/pins/pin.js +1 -1
  605. package/dist/core/verify/pins/pin.js.map +1 -1
  606. package/dist/core/verify/read/file.js +224 -0
  607. package/dist/core/verify/read/file.js.map +1 -0
  608. package/dist/core/verify/{file.js → read/shape.js} +16 -104
  609. package/dist/core/verify/read/shape.js.map +1 -0
  610. package/dist/core/verify/read/union.js +105 -0
  611. package/dist/core/verify/read/union.js.map +1 -0
  612. package/dist/core/verify/record.js +35 -12
  613. package/dist/core/verify/record.js.map +1 -1
  614. package/dist/core/verify/store/commit.js +40 -30
  615. package/dist/core/verify/store/commit.js.map +1 -1
  616. package/dist/core/verify/store/render.js +8 -4
  617. package/dist/core/verify/store/render.js.map +1 -1
  618. package/dist/core/vocabulary/issue.js +1 -0
  619. package/dist/core/vocabulary/issue.js.map +1 -1
  620. package/dist/core/vocabulary/maturity.js +12 -2
  621. package/dist/core/vocabulary/maturity.js.map +1 -1
  622. package/examples/README.md +21 -6
  623. package/examples/docs/architecture/landscape.likec4 +8 -0
  624. package/examples/docs/architecture/usecases/order-notification.likec4 +6 -2
  625. package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-service/asyncapi.yaml +11 -8
  626. package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-split-service/openapi.yaml +6 -6
  627. package/examples/docs/features/archive/FEAT-088-refunds/specs/payment-service/openapi.yaml +13 -10
  628. package/examples/docs/services/order-service/asyncapi.yaml +3 -2
  629. package/examples/docs/services/platform/notification-service/asyncapi.yaml +2 -1
  630. package/package.json +1 -1
  631. package/dist/commands/validate/checks/vocabulary.js +0 -16
  632. package/dist/commands/validate/checks/vocabulary.js.map +0 -1
  633. package/dist/core/agent/agents-md/artifacts.js +0 -221
  634. package/dist/core/agent/agents-md/artifacts.js.map +0 -1
  635. package/dist/core/agent/agents-md/cycle.js +0 -180
  636. package/dist/core/agent/agents-md/cycle.js.map +0 -1
  637. package/dist/core/agent/agents-md/shipped/archive-gate.js +0 -98
  638. package/dist/core/agent/agents-md/shipped/archive-gate.js.map +0 -1
  639. package/dist/core/agent/agents-md/spine.js +0 -40
  640. package/dist/core/agent/agents-md/spine.js.map +0 -1
  641. package/dist/core/coherence/events/merged.js +0 -40
  642. package/dist/core/coherence/events/merged.js.map +0 -1
  643. package/dist/core/diff/current-state.js +0 -96
  644. package/dist/core/diff/current-state.js.map +0 -1
  645. package/dist/core/diff/victims.js +0 -256
  646. package/dist/core/diff/victims.js.map +0 -1
  647. package/dist/core/staging/snapshot.js.map +0 -1
  648. package/dist/core/verify/file.js.map +0 -1
@@ -8,7 +8,7 @@
8
8
  * `lockIsStale` only reports it to `doctor`. They were written as twins and
9
9
  * stay together so they cannot drift into two definitions of a dead holder.
10
10
  */
11
- import { link, readFile, unlink, writeFile } from "node:fs/promises";
11
+ import { link, readFile, rename, writeFile } from "node:fs/promises";
12
12
  import { hostname } from "node:os";
13
13
  import { join } from "node:path";
14
14
  // Every `isRecord` below guards a record loam itself wrote and is reading back
@@ -61,18 +61,24 @@ export class DocsBusyError extends Error {
61
61
  * microsecond. A create that dies before its link leaves only a staging temp
62
62
  * `doctor` already names as litter. A holder that died without releasing would
63
63
  * otherwise wedge the repo forever, so a lock naming a pid on THIS host that
64
- * no longer exists is broken once and retried — two racers could both break
65
- * the same stale lock, which is why the pre-image check in `swapStaged`
66
- * remains the second line of defence rather than an optimisation.
64
+ * no longer exists is broken once and retried. Breaking removes only the
65
+ * instance that was judged dead (see `breakStaleLock`), never a successor's
66
+ * live lock. This comment used to accept that two racers could both break the
67
+ * same stale lock, trusting the pre-image check in `swapStaged` as the second
68
+ * line of defence. That check is still kept, and is still not an
69
+ * optimisation. But it guards only the swap. A second holder's RECOVERY reads
70
+ * the first holder's in-flight journal as an interrupted commit and sweeps
71
+ * its staged temps, and no compare-and-set sits in front of that.
67
72
  */
68
73
  /**
69
74
  * Distinguishes concurrent acquires INSIDE one process. `tempPath` salts with
70
75
  * pid and a millisecond timestamp, which is unique across processes and not
71
76
  * within one: two in-process acquirers in the same millisecond shared a temp
72
77
  * name, the first's cleanup deleted it under the second, and the second's
73
- * link(2) surfaced ENOENT as `internal`. A bare counter is module-level
74
- * mutable state, tolerated here because it feeds nothing but name uniqueness —
75
- * no behaviour ever branches on its value.
78
+ * link(2) surfaced ENOENT as `internal`. The private name a stale-lock break
79
+ * takes the lock to is salted from the same counter, for the same reason. A
80
+ * bare counter is module-level mutable state, tolerated here because it feeds
81
+ * nothing but name uniqueness — no behaviour ever branches on its value.
76
82
  */
77
83
  let lockAcquireSeq = 0;
78
84
  export async function acquireDocsLock(docsDir) {
@@ -166,20 +172,76 @@ function pidGone(pid) {
166
172
  return err.code === "ESRCH";
167
173
  }
168
174
  }
169
- /** Remove a lock whose holder is a dead process on this same host. Returns whether it did. */
175
+ /**
176
+ * Remove a lock whose holder is a dead process on this same host, and only the
177
+ * lock INSTANCE that verdict was about. Returns whether it did.
178
+ *
179
+ * The verdict comes from a read, the removal acts on a path, and other writers
180
+ * move in between. The removal used to be a bare unlink(2) of the path. Under
181
+ * CPU load a waiter read the holder's payload, then the holder released
182
+ * NORMALLY and exited, then a third writer linked a fresh lock, and only then
183
+ * did kill(pid, 0) answer ESRCH. The unlink deleted that third writer's LIVE
184
+ * lock. Two `loam gherkin` runs then shared one commit window, and the
185
+ * second's recovery read the first's in-flight journal as "interrupted" and
186
+ * swept its staged temps. The first's rename failed ENOENT
187
+ * (test/stress-gherkin.test.ts found it; test/lock-break.test.ts opens each
188
+ * window on purpose).
189
+ *
190
+ * So the removal is bound to the judged instance, twice:
191
+ * - the lock is re-read AFTER the verdict and must still name the judged
192
+ * holder. A holder that released normally removed its file before it
193
+ * exited, so a dead holder's lock still in place can only be a crashed
194
+ * one's, which is the one lock a break is for;
195
+ * - it is then TAKEN by rename(2) to a private name, which exactly one of
196
+ * several concurrent breakers can win, and judged in hand. If what moved is
197
+ * not the judged instance (another waiter broke the same crashed lock first
198
+ * and linked its own in between), it goes back by link(2), which never
199
+ * overwrites.
200
+ * One gap stays open, between that take and the put-back. A third writer can
201
+ * link in it, and then the displaced lock stays down. Or the displaced holder
202
+ * can release in it, and then the put-back restores a lock nobody holds, which
203
+ * stands until that process exits. Either needs a crashed holder AND two
204
+ * breakers racing for its lock. The gap is named here rather than closed,
205
+ * because closing it needs a kernel lock (flock) that Node does not expose and
206
+ * loam will not add a dependency for.
207
+ */
170
208
  async function breakStaleLock(path) {
171
- const holder = await readLockHolder(path);
209
+ const judged = await readLockHolder(path);
172
210
  // Unreadable is deliberately NOT breakable: a lock we cannot understand is
173
211
  // the one case where guessing is worst. `doctor` reports it as damage.
174
- if (holder.kind !== "held" || holder.host !== hostname() || !pidGone(holder.pid))
212
+ if (judged.kind !== "held" || judged.host !== hostname() || !pidGone(judged.pid))
213
+ return false;
214
+ if (!sameHolder(await readLockHolder(path), judged))
175
215
  return false;
216
+ lockAcquireSeq += 1;
217
+ const taken = tempPath(path, lockAcquireSeq);
176
218
  try {
177
- await unlink(path);
178
- return true;
219
+ await rename(path, taken);
179
220
  }
180
221
  catch {
222
+ // Another breaker took it first, or the platform refused the rename (a
223
+ // Windows handle still open on it). Either way the next link(2) attempt
224
+ // decides, not this one.
181
225
  return false;
182
226
  }
227
+ if (sameHolder(await readLockHolder(taken), judged)) {
228
+ await quietRm(taken);
229
+ return true;
230
+ }
231
+ try {
232
+ await link(taken, path);
233
+ }
234
+ catch {
235
+ // The gap named above: a third writer linked in between, and putting the
236
+ // displaced lock back would overwrite a live one. It stays down. Its holder
237
+ // is unprotected from here, and nothing this process can do fixes that.
238
+ }
239
+ await quietRm(taken);
240
+ return false;
241
+ }
242
+ /** Is `now` the very lock instance `judged` was read from? Pid, host and timestamp are its identity. */
243
+ function sameHolder(now, judged) {
244
+ return now.kind === "held" && now.pid === judged.pid && now.host === judged.host && now.at === judged.at;
183
245
  }
184
246
  export async function describeLock(path) {
185
247
  const holder = await readLockHolder(path);
@@ -1 +1 @@
1
- {"version":3,"file":"lock.js","sourceRoot":"","sources":["../../../src/core/staging/lock.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The docs repo's advisory lock: take it, describe its holder, and break it\n * when that holder is a pid on this host that no longer exists.\n *\n * A module of its own because it is the only rule here that spans the whole\n * fleet rather than one file, and because the two readers of a held lock have\n * to agree on what \"stale\" means — `breakStaleLock` acts on the answer,\n * `lockIsStale` only reports it to `doctor`. They were written as twins and\n * stay together so they cannot drift into two definitions of a dead holder.\n */\nimport { link, readFile, unlink, writeFile } from \"node:fs/promises\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\n// Every `isRecord` below guards a record loam itself wrote and is reading back\n// — the lock, the snapshot manifest — whose fields are asked for on the very\n// next line, inside a REFUSAL path. `JSON.parse` answers with any JSON value,\n// not with an object, and that is where a thrown `TypeError` costs the most: it\n// does not make the command fail safely, it replaces a designed refusal with\n// `internal`.\nimport { isRecord } from \"../kernel/records.js\";\nimport { quietRm, tempPath } from \"./commit.js\";\n\n/** The lock file, in the docs repo root — one writer at a time across the whole fleet. */\nexport const DOCS_LOCK = \".loam-lock\";\n\n/**\n * How long a WAITING writer sits out a live holder before answering\n * `docs-busy`. One spelling for every writer that waits — including gherkin,\n * whose lock guards the SERVICE repo's emission root rather than the docs\n * repo: long enough to cover another writer's sub-second commit window with a\n * margin for a loaded host, bounded so a wedged lock cannot hang anyone.\n */\nexport const LOCK_WAIT_MS = 5_000;\n\n/** Another writer holds the docs repo. Commands map this to the `docs-busy` envelope code. */\nexport class DocsBusyError extends Error {\n override readonly name = \"DocsBusyError\";\n\n constructor(\n readonly lockPath: string,\n holder: string,\n ) {\n super(\n `another loam command is writing this docs repo (${holder}) — nothing was read or written. ` +\n `Wait for it to finish, or remove ${lockPath} if no such command is running.`,\n );\n }\n}\n\n/**\n * Take the docs repo's advisory lock for the whole plan+commit window, and\n * return the release.\n *\n * Advisory, and deliberately coarse: the destructive commands write the SAME\n * three shared files (a service spec, a service contract, the one landscape),\n * so two of them overlapping is not a merge to be resolved but a merge one of\n * them computed against a snapshot that no longer exists. Two archives used to\n * plan against the same landscape bytes and the second's splice silently\n * dropped the first's additions — exit 0 on both, and a `validate` that stayed\n * green because the surviving document was perfectly coherent.\n *\n * link(2) over a fully-written sibling is the primitive: as atomic as the\n * O_EXCL create it replaced, needs no daemon, and leaves a file a human can\n * read and delete — but unlike open-then-write, the lock file can never be\n * OBSERVED empty. `writeFile(path, …, \"wx\")` published existence first and\n * payload a syscall later, and in that window (or after a crash inside it) the\n * lock named no holder: `doctor` now grades exactly that shape as damage whose\n * fix is deletion, so the healthy path must never wear it, even for a\n * microsecond. A create that dies before its link leaves only a staging temp\n * `doctor` already names as litter. A holder that died without releasing would\n * otherwise wedge the repo forever, so a lock naming a pid on THIS host that\n * no longer exists is broken once and retried — two racers could both break\n * the same stale lock, which is why the pre-image check in `swapStaged`\n * remains the second line of defence rather than an optimisation.\n */\n/**\n * Distinguishes concurrent acquires INSIDE one process. `tempPath` salts with\n * pid and a millisecond timestamp, which is unique across processes and not\n * within one: two in-process acquirers in the same millisecond shared a temp\n * name, the first's cleanup deleted it under the second, and the second's\n * link(2) surfaced ENOENT as `internal`. A bare counter is module-level\n * mutable state, tolerated here because it feeds nothing but name uniqueness —\n * no behaviour ever branches on its value.\n */\nlet lockAcquireSeq = 0;\n\nexport async function acquireDocsLock(docsDir: string): Promise<() => Promise<void>> {\n const path = join(docsDir, DOCS_LOCK);\n const payload = `${JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() })}\\n`;\n lockAcquireSeq += 1;\n const tmp = tempPath(path, lockAcquireSeq);\n await writeFile(tmp, payload);\n try {\n for (let attempt = 0; ; attempt += 1) {\n try {\n await link(tmp, path);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n if (attempt === 0 && (await breakStaleLock(path))) continue;\n throw new DocsBusyError(path, await describeLock(path));\n }\n let released = false;\n return async () => {\n if (released) return;\n released = true;\n await quietRm(path);\n };\n }\n } finally {\n await quietRm(tmp);\n }\n}\n\n/**\n * Take the docs lock, waiting out a live holder instead of refusing on first\n * contact. Same lock, different caller contract: `archive` refuses fast because\n * its holder may be mid-merge for seconds and its caller can re-run, but two\n * `verify --record` runs for different services of one feature are BOTH\n * supposed to land — the second must wait for the first's sub-second window and\n * then merge over the record it left, or one service's attestation is refused\n * for no reason a user can see. The wait is bounded: a holder that outlives\n * `waitMs` yields the same `DocsBusyError` the fast form throws, so the caller\n * maps it to `docs-busy` either way and nothing hangs on a wedged lock.\n */\nexport async function acquireDocsLockWaiting(docsDir: string, waitMs: number): Promise<() => Promise<void>> {\n const deadline = Date.now() + waitMs;\n for (;;) {\n try {\n return await acquireDocsLock(docsDir);\n } catch (err) {\n if (!(err instanceof DocsBusyError) || Date.now() >= deadline) throw err;\n }\n // Short enough that a released lock is picked up before a human notices;\n // long enough that the poll does not busy-spin the directory.\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\n/**\n * One read of the lock file, one interpretation, shared by every question\n * asked about it. These used to be four separate parse-and-guard copies\n * (`breakStaleLock`, `describeLock`, `lockIsStale`, `lockUnreadable`), each\n * catching to a different answer — which is how `doctor` came to read the file\n * three times in a row and report three different states of a lock that was\n * released between its reads.\n *\n * `absent` and `unreadable` are kept apart on purpose: a lock that is GONE is\n * the ordinary end of every command, while a lock that exists and cannot name\n * a holder — empty bytes from a crash between create and flush, JSON that is\n * not a record, a record with no string `host` or numeric `pid` — is damage:\n * `breakStaleLock` rightly refuses to guess about it, so nothing will ever\n * release it. A different host's lock is legible and simply not ours to judge.\n */\ntype LockHolder =\n | { kind: \"absent\" }\n | { kind: \"unreadable\" }\n | { kind: \"held\"; host: string; pid: number; at: string | undefined };\n\nasync function readLockHolder(path: string): Promise<LockHolder> {\n let text: string;\n try {\n text = await readFile(path, \"utf8\");\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ENOENT\" ? { kind: \"absent\" } : { kind: \"unreadable\" };\n }\n let holder: unknown;\n try {\n holder = JSON.parse(text);\n } catch {\n return { kind: \"unreadable\" };\n }\n // A lock file holding `null` (or a bare string, or an array) is JSON this\n // parses and nothing else understands. The dereference below used to run\n // outside the guard in `acquireDocsLock`'s breaker and threw, replacing the\n // `docs-busy` refusal — the designed answer — with an `internal` envelope.\n if (!isRecord(holder) || typeof holder.host !== \"string\" || typeof holder.pid !== \"number\") {\n return { kind: \"unreadable\" };\n }\n return { kind: \"held\", host: holder.host, pid: holder.pid, at: typeof holder.at === \"string\" ? holder.at : undefined };\n}\n\n/** Does this pid no longer exist on this host? Signal 0 asks without delivering; EPERM is alive-but-somebody-else's. */\nfunction pidGone(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return false;\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ESRCH\";\n }\n}\n\n/** Remove a lock whose holder is a dead process on this same host. Returns whether it did. */\nasync function breakStaleLock(path: string): Promise<boolean> {\n const holder = await readLockHolder(path);\n // Unreadable is deliberately NOT breakable: a lock we cannot understand is\n // the one case where guessing is worst. `doctor` reports it as damage.\n if (holder.kind !== \"held\" || holder.host !== hostname() || !pidGone(holder.pid)) return false;\n try {\n await unlink(path);\n return true;\n } catch {\n return false;\n }\n}\n\nexport async function describeLock(path: string): Promise<string> {\n const holder = await readLockHolder(path);\n return holder.kind === \"held\" ? `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}` : `see ${path}`;\n}\n\n/**\n * The lock's whole state for `doctor`, from ONE read. Null means no lock.\n * Three sequential reads used to answer this — exists, describe, stale — and a\n * lock released between them made `doctor` describe a file that was gone,\n * escalating an ordinary sub-second commit window into a blocker.\n */\nexport async function lockResidue(path: string): Promise<{ holder: string; stale: boolean; unreadable: boolean } | null> {\n const holder = await readLockHolder(path);\n if (holder.kind === \"absent\") return null;\n if (holder.kind === \"unreadable\") return { holder: `see ${path}`, stale: false, unreadable: true };\n return {\n holder: `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}`,\n stale: holder.host === hostname() && pidGone(holder.pid),\n unreadable: false,\n };\n}\n\n"]}
1
+ {"version":3,"file":"lock.js","sourceRoot":"","sources":["../../../src/core/staging/lock.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The docs repo's advisory lock: take it, describe its holder, and break it\n * when that holder is a pid on this host that no longer exists.\n *\n * A module of its own because it is the only rule here that spans the whole\n * fleet rather than one file, and because the two readers of a held lock have\n * to agree on what \"stale\" means — `breakStaleLock` acts on the answer,\n * `lockIsStale` only reports it to `doctor`. They were written as twins and\n * stay together so they cannot drift into two definitions of a dead holder.\n */\nimport { link, readFile, rename, writeFile } from \"node:fs/promises\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\n// Every `isRecord` below guards a record loam itself wrote and is reading back\n// — the lock, the snapshot manifest — whose fields are asked for on the very\n// next line, inside a REFUSAL path. `JSON.parse` answers with any JSON value,\n// not with an object, and that is where a thrown `TypeError` costs the most: it\n// does not make the command fail safely, it replaces a designed refusal with\n// `internal`.\nimport { isRecord } from \"../kernel/records.js\";\nimport { quietRm, tempPath } from \"./commit.js\";\n\n/** The lock file, in the docs repo root — one writer at a time across the whole fleet. */\nexport const DOCS_LOCK = \".loam-lock\";\n\n/**\n * How long a WAITING writer sits out a live holder before answering\n * `docs-busy`. One spelling for every writer that waits — including gherkin,\n * whose lock guards the SERVICE repo's emission root rather than the docs\n * repo: long enough to cover another writer's sub-second commit window with a\n * margin for a loaded host, bounded so a wedged lock cannot hang anyone.\n */\nexport const LOCK_WAIT_MS = 5_000;\n\n/** Another writer holds the docs repo. Commands map this to the `docs-busy` envelope code. */\nexport class DocsBusyError extends Error {\n override readonly name = \"DocsBusyError\";\n\n constructor(\n readonly lockPath: string,\n holder: string,\n ) {\n super(\n `another loam command is writing this docs repo (${holder}) — nothing was read or written. ` +\n `Wait for it to finish, or remove ${lockPath} if no such command is running.`,\n );\n }\n}\n\n/**\n * Take the docs repo's advisory lock for the whole plan+commit window, and\n * return the release.\n *\n * Advisory, and deliberately coarse: the destructive commands write the SAME\n * three shared files (a service spec, a service contract, the one landscape),\n * so two of them overlapping is not a merge to be resolved but a merge one of\n * them computed against a snapshot that no longer exists. Two archives used to\n * plan against the same landscape bytes and the second's splice silently\n * dropped the first's additions — exit 0 on both, and a `validate` that stayed\n * green because the surviving document was perfectly coherent.\n *\n * link(2) over a fully-written sibling is the primitive: as atomic as the\n * O_EXCL create it replaced, needs no daemon, and leaves a file a human can\n * read and delete — but unlike open-then-write, the lock file can never be\n * OBSERVED empty. `writeFile(path, …, \"wx\")` published existence first and\n * payload a syscall later, and in that window (or after a crash inside it) the\n * lock named no holder: `doctor` now grades exactly that shape as damage whose\n * fix is deletion, so the healthy path must never wear it, even for a\n * microsecond. A create that dies before its link leaves only a staging temp\n * `doctor` already names as litter. A holder that died without releasing would\n * otherwise wedge the repo forever, so a lock naming a pid on THIS host that\n * no longer exists is broken once and retried. Breaking removes only the\n * instance that was judged dead (see `breakStaleLock`), never a successor's\n * live lock. This comment used to accept that two racers could both break the\n * same stale lock, trusting the pre-image check in `swapStaged` as the second\n * line of defence. That check is still kept, and is still not an\n * optimisation. But it guards only the swap. A second holder's RECOVERY reads\n * the first holder's in-flight journal as an interrupted commit and sweeps\n * its staged temps, and no compare-and-set sits in front of that.\n */\n/**\n * Distinguishes concurrent acquires INSIDE one process. `tempPath` salts with\n * pid and a millisecond timestamp, which is unique across processes and not\n * within one: two in-process acquirers in the same millisecond shared a temp\n * name, the first's cleanup deleted it under the second, and the second's\n * link(2) surfaced ENOENT as `internal`. The private name a stale-lock break\n * takes the lock to is salted from the same counter, for the same reason. A\n * bare counter is module-level mutable state, tolerated here because it feeds\n * nothing but name uniqueness — no behaviour ever branches on its value.\n */\nlet lockAcquireSeq = 0;\n\nexport async function acquireDocsLock(docsDir: string): Promise<() => Promise<void>> {\n const path = join(docsDir, DOCS_LOCK);\n const payload = `${JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() })}\\n`;\n lockAcquireSeq += 1;\n const tmp = tempPath(path, lockAcquireSeq);\n await writeFile(tmp, payload);\n try {\n for (let attempt = 0; ; attempt += 1) {\n try {\n await link(tmp, path);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n if (attempt === 0 && (await breakStaleLock(path))) continue;\n throw new DocsBusyError(path, await describeLock(path));\n }\n let released = false;\n return async () => {\n if (released) return;\n released = true;\n await quietRm(path);\n };\n }\n } finally {\n await quietRm(tmp);\n }\n}\n\n/**\n * Take the docs lock, waiting out a live holder instead of refusing on first\n * contact. Same lock, different caller contract: `archive` refuses fast because\n * its holder may be mid-merge for seconds and its caller can re-run, but two\n * `verify --record` runs for different services of one feature are BOTH\n * supposed to land — the second must wait for the first's sub-second window and\n * then merge over the record it left, or one service's attestation is refused\n * for no reason a user can see. The wait is bounded: a holder that outlives\n * `waitMs` yields the same `DocsBusyError` the fast form throws, so the caller\n * maps it to `docs-busy` either way and nothing hangs on a wedged lock.\n */\nexport async function acquireDocsLockWaiting(docsDir: string, waitMs: number): Promise<() => Promise<void>> {\n const deadline = Date.now() + waitMs;\n for (;;) {\n try {\n return await acquireDocsLock(docsDir);\n } catch (err) {\n if (!(err instanceof DocsBusyError) || Date.now() >= deadline) throw err;\n }\n // Short enough that a released lock is picked up before a human notices;\n // long enough that the poll does not busy-spin the directory.\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\n/**\n * One read of the lock file, one interpretation, shared by every question\n * asked about it. These used to be four separate parse-and-guard copies\n * (`breakStaleLock`, `describeLock`, `lockIsStale`, `lockUnreadable`), each\n * catching to a different answer — which is how `doctor` came to read the file\n * three times in a row and report three different states of a lock that was\n * released between its reads.\n *\n * `absent` and `unreadable` are kept apart on purpose: a lock that is GONE is\n * the ordinary end of every command, while a lock that exists and cannot name\n * a holder — empty bytes from a crash between create and flush, JSON that is\n * not a record, a record with no string `host` or numeric `pid` — is damage:\n * `breakStaleLock` rightly refuses to guess about it, so nothing will ever\n * release it. A different host's lock is legible and simply not ours to judge.\n */\ntype LockHolder =\n | { kind: \"absent\" }\n | { kind: \"unreadable\" }\n | { kind: \"held\"; host: string; pid: number; at: string | undefined };\n\nasync function readLockHolder(path: string): Promise<LockHolder> {\n let text: string;\n try {\n text = await readFile(path, \"utf8\");\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ENOENT\" ? { kind: \"absent\" } : { kind: \"unreadable\" };\n }\n let holder: unknown;\n try {\n holder = JSON.parse(text);\n } catch {\n return { kind: \"unreadable\" };\n }\n // A lock file holding `null` (or a bare string, or an array) is JSON this\n // parses and nothing else understands. The dereference below used to run\n // outside the guard in `acquireDocsLock`'s breaker and threw, replacing the\n // `docs-busy` refusal — the designed answer — with an `internal` envelope.\n if (!isRecord(holder) || typeof holder.host !== \"string\" || typeof holder.pid !== \"number\") {\n return { kind: \"unreadable\" };\n }\n return { kind: \"held\", host: holder.host, pid: holder.pid, at: typeof holder.at === \"string\" ? holder.at : undefined };\n}\n\n/** Does this pid no longer exist on this host? Signal 0 asks without delivering; EPERM is alive-but-somebody-else's. */\nfunction pidGone(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return false;\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ESRCH\";\n }\n}\n\n/**\n * Remove a lock whose holder is a dead process on this same host, and only the\n * lock INSTANCE that verdict was about. Returns whether it did.\n *\n * The verdict comes from a read, the removal acts on a path, and other writers\n * move in between. The removal used to be a bare unlink(2) of the path. Under\n * CPU load a waiter read the holder's payload, then the holder released\n * NORMALLY and exited, then a third writer linked a fresh lock, and only then\n * did kill(pid, 0) answer ESRCH. The unlink deleted that third writer's LIVE\n * lock. Two `loam gherkin` runs then shared one commit window, and the\n * second's recovery read the first's in-flight journal as \"interrupted\" and\n * swept its staged temps. The first's rename failed ENOENT\n * (test/stress-gherkin.test.ts found it; test/lock-break.test.ts opens each\n * window on purpose).\n *\n * So the removal is bound to the judged instance, twice:\n * - the lock is re-read AFTER the verdict and must still name the judged\n * holder. A holder that released normally removed its file before it\n * exited, so a dead holder's lock still in place can only be a crashed\n * one's, which is the one lock a break is for;\n * - it is then TAKEN by rename(2) to a private name, which exactly one of\n * several concurrent breakers can win, and judged in hand. If what moved is\n * not the judged instance (another waiter broke the same crashed lock first\n * and linked its own in between), it goes back by link(2), which never\n * overwrites.\n * One gap stays open, between that take and the put-back. A third writer can\n * link in it, and then the displaced lock stays down. Or the displaced holder\n * can release in it, and then the put-back restores a lock nobody holds, which\n * stands until that process exits. Either needs a crashed holder AND two\n * breakers racing for its lock. The gap is named here rather than closed,\n * because closing it needs a kernel lock (flock) that Node does not expose and\n * loam will not add a dependency for.\n */\nasync function breakStaleLock(path: string): Promise<boolean> {\n const judged = await readLockHolder(path);\n // Unreadable is deliberately NOT breakable: a lock we cannot understand is\n // the one case where guessing is worst. `doctor` reports it as damage.\n if (judged.kind !== \"held\" || judged.host !== hostname() || !pidGone(judged.pid)) return false;\n if (!sameHolder(await readLockHolder(path), judged)) return false;\n lockAcquireSeq += 1;\n const taken = tempPath(path, lockAcquireSeq);\n try {\n await rename(path, taken);\n } catch {\n // Another breaker took it first, or the platform refused the rename (a\n // Windows handle still open on it). Either way the next link(2) attempt\n // decides, not this one.\n return false;\n }\n if (sameHolder(await readLockHolder(taken), judged)) {\n await quietRm(taken);\n return true;\n }\n try {\n await link(taken, path);\n } catch {\n // The gap named above: a third writer linked in between, and putting the\n // displaced lock back would overwrite a live one. It stays down. Its holder\n // is unprotected from here, and nothing this process can do fixes that.\n }\n await quietRm(taken);\n return false;\n}\n\n/** Is `now` the very lock instance `judged` was read from? Pid, host and timestamp are its identity. */\nfunction sameHolder(now: LockHolder, judged: Extract<LockHolder, { kind: \"held\" }>): boolean {\n return now.kind === \"held\" && now.pid === judged.pid && now.host === judged.host && now.at === judged.at;\n}\n\nexport async function describeLock(path: string): Promise<string> {\n const holder = await readLockHolder(path);\n return holder.kind === \"held\" ? `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}` : `see ${path}`;\n}\n\n/**\n * The lock's whole state for `doctor`, from ONE read. Null means no lock.\n * Three sequential reads used to answer this — exists, describe, stale — and a\n * lock released between them made `doctor` describe a file that was gone,\n * escalating an ordinary sub-second commit window into a blocker.\n */\nexport async function lockResidue(path: string): Promise<{ holder: string; stale: boolean; unreadable: boolean } | null> {\n const holder = await readLockHolder(path);\n if (holder.kind === \"absent\") return null;\n if (holder.kind === \"unreadable\") return { holder: `see ${path}`, stale: false, unreadable: true };\n return {\n holder: `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}`,\n stale: holder.host === hostname() && pidGone(holder.pid),\n unreadable: false,\n };\n}\n\n"]}
@@ -22,11 +22,8 @@ const COMMIT_INTENT_VERSION = 1;
22
22
  /**
23
23
  * Write the intent record and get it onto the disk before the first swap.
24
24
  *
25
- * fsync, not just write: the point of the record is to survive the crash that
26
- * interrupts the commit, and a record still sitting in the page cache when the
27
- * machine dies describes nothing. The directory entry is fsynced too where the
28
- * platform allows opening a directory — without that the file's bytes can be
29
- * durable while its name is not.
25
+ * fsync, not just write — through `writeDurable`, whose doc says why: the
26
+ * point of the record is to survive the crash that interrupts the commit.
30
27
  */
31
28
  export async function writeCommitIntent(docsDir, spec, staged) {
32
29
  const intent = {
@@ -1 +1 @@
1
- {"version":3,"file":"intent.js","sourceRoot":"","sources":["../../../../src/core/staging/recovery/intent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAoB,MAAM,cAAc,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,+EAA+E;AAC/E,MAAM,qBAAqB,GAAG,CAAC,CAAC;AAiChC;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,OAAe,EACf,IAMC,EACD,MAAqB;IAErB,MAAM,MAAM,GAAiB;QAC3B,OAAO,EAAE,qBAAqB;QAC9B,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,IAAI,EAAE,QAAQ,EAAE;QAChB,EAAE,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QAC5B,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,QAAQ,EAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,QAAQ,CAAC;QAC1C,MAAM,EAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC;QACtC,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACxB,IAAI,EAAE,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC;YACrC,MAAM,EAAE,CAAC,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;YACnD,KAAK,EAAE,CAAC,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;SACrD,CAAC,CAAC;KACJ,CAAC;IACF,MAAM,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,aAAa,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AAC3F,CAAC;AAED,uFAAuF;AACvF,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,OAAe;IACrD,MAAM,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC;AAC9C,CAAC;AAED,6EAA6E;AAC7E,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,OAAe;IACpD,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC;IAC1C,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,CAAC,GAAG,MAA+B,CAAC;IAC1C,IAAI,CAAC,KAAK,IAAI,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACrD,IAAI,CAAC,CAAC,OAAO,KAAK,qBAAqB;QAAE,OAAO,IAAI,CAAC;IACrD,IAAI,CAAC,CAAC,OAAO,KAAK,SAAS,IAAI,CAAC,CAAC,OAAO,KAAK,WAAW;QAAE,OAAO,IAAI,CAAC;IACtE,IAAI,CAAC,CAAC,OAAO,KAAK,QAAQ,IAAI,CAAC,CAAC,OAAO,KAAK,OAAO;QAAE,OAAO,IAAI,CAAC;IACjE,IAAI,OAAO,CAAC,CAAC,OAAO,KAAK,QAAQ,IAAI,OAAO,CAAC,CAAC,QAAQ,KAAK,QAAQ,IAAI,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACjH,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACzC,2EAA2E;IAC3E,uEAAuE;IACvE,oEAAoE;IACpE,MAAM,MAAM,GAAG,CAAC,GAAW,EAAW,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACvG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1D,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC;QACxB,IAAI,OAAO,CAAC,EAAE,IAAI,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QAC7C,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACvE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;IACnC,CAAC;IACD,OAAO,CAAiB,CAAC;AAC3B,CAAC;AAED,SAAS,cAAc,CAAC,KAAc;IACpC,OAAO,KAAK,KAAK,IAAI,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC;AAC3C,CAAC","sourcesContent":["/**\n * The `.loam-commit` journal — written before the first swap, cleared after the\n * last — and the record that makes an interrupted commit detectable at all.\n *\n * Reading and writing the record is kept apart from acting on it\n * (`./recover.ts`) because a corrupt record has to stay describable: `doctor`\n * reports a `.loam-commit` it cannot parse, and a module that parsed and\n * repaired in one step would have no way to say that without also attempting\n * the repair.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\nimport { repoPath } from \"../../envelope/json.js\";\nimport { isPathInside } from \"../../kernel/path-safety.js\";\nimport { COMMIT_INTENT, writeDurable } from \"../interrupted.js\";\nimport { isDigest, sha256, type StagedWrite } from \"../writes.js\";\nimport { quietRm } from \"../commit.js\";\n\n/** The version-1 record: archive's and unarchive's snapshot-backed journal. */\nconst COMMIT_INTENT_VERSION = 1;\n\nexport interface CommitIntentFile {\n /** Docs-repo-relative path, forward slashes. */\n path: string;\n /** sha256 of the bytes before the swap; null when the swap creates the file. */\n before: string | null;\n /** sha256 of the bytes the swap writes; null when the swap deletes the file. */\n after: string | null;\n}\n\nexport interface CommitIntent {\n version: number;\n /** Which command was committing — the sentence a human reads, not a branch. */\n command: \"archive\" | \"unarchive\";\n /**\n * The state a repair returns the docs to. `archive` undoes (its rollback path\n * would have restored the pre-images anyway); `unarchive` finishes (its target\n * bytes ARE the pre-images, and the merged text it was replacing is recorded\n * nowhere else).\n */\n restore: \"before\" | \"after\";\n pid: number;\n host: string;\n at: string;\n feature: string;\n /** Docs-repo-relative feature directory the commit's final move takes FROM. */\n moveFrom: string;\n /** …and TO. Both landed ⇒ every earlier step landed too. */\n moveTo: string;\n files: CommitIntentFile[];\n}\n\n/**\n * Write the intent record and get it onto the disk before the first swap.\n *\n * fsync, not just write: the point of the record is to survive the crash that\n * interrupts the commit, and a record still sitting in the page cache when the\n * machine dies describes nothing. The directory entry is fsynced too where the\n * platform allows opening a directory — without that the file's bytes can be\n * durable while its name is not.\n */\nexport async function writeCommitIntent(\n docsDir: string,\n spec: {\n command: CommitIntent[\"command\"];\n restore: CommitIntent[\"restore\"];\n feature: string;\n moveFrom: string;\n moveTo: string;\n },\n staged: StagedWrite[],\n): Promise<void> {\n const intent: CommitIntent = {\n version: COMMIT_INTENT_VERSION,\n command: spec.command,\n restore: spec.restore,\n pid: process.pid,\n host: hostname(),\n at: new Date().toISOString(),\n feature: spec.feature,\n moveFrom: repoPath(docsDir, spec.moveFrom),\n moveTo: repoPath(docsDir, spec.moveTo),\n files: staged.map((s) => ({\n path: repoPath(docsDir, s.write.path),\n before: s.before === null ? null : sha256(s.before),\n after: s.content === null ? null : sha256(s.content),\n })),\n };\n await writeDurable(join(docsDir, COMMIT_INTENT), JSON.stringify(intent, null, 2) + \"\\n\");\n}\n\n/** The commit finished (or was rolled back): there is nothing left to recover from. */\nexport async function clearCommitIntent(docsDir: string): Promise<void> {\n await quietRm(join(docsDir, COMMIT_INTENT));\n}\n\n/** The intent record as written, or null when it is absent or unreadable. */\nexport async function readCommitIntent(docsDir: string): Promise<CommitIntent | null> {\n const path = join(docsDir, COMMIT_INTENT);\n if (!existsSync(path)) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(await readFile(path, \"utf8\"));\n } catch {\n return null;\n }\n const i = parsed as Partial<CommitIntent>;\n if (i === null || typeof i !== \"object\") return null;\n if (i.version !== COMMIT_INTENT_VERSION) return null;\n if (i.command !== \"archive\" && i.command !== \"unarchive\") return null;\n if (i.restore !== \"before\" && i.restore !== \"after\") return null;\n if (typeof i.feature !== \"string\" || typeof i.moveFrom !== \"string\" || typeof i.moveTo !== \"string\") return null;\n if (!Array.isArray(i.files)) return null;\n // The journal is untrusted input, and recovery writes over what it names —\n // the same containment the version-2 reader enforces, because an entry\n // escaping the docs repo would turn repair into an arbitrary write.\n const inside = (rel: string): boolean => isPathInside(join(docsDir), join(docsDir, ...rel.split(\"/\")));\n if (!inside(i.moveFrom) || !inside(i.moveTo)) return null;\n for (const f of i.files) {\n if (typeof f?.path !== \"string\") return null;\n if (!isDigestOrNull(f.before) || !isDigestOrNull(f.after)) return null;\n if (!inside(f.path)) return null;\n }\n return i as CommitIntent;\n}\n\nfunction isDigestOrNull(value: unknown): boolean {\n return value === null || isDigest(value);\n}\n\n"]}
1
+ {"version":3,"file":"intent.js","sourceRoot":"","sources":["../../../../src/core/staging/recovery/intent.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The `.loam-commit` journal — written before the first swap, cleared after the\n * last — and the record that makes an interrupted commit detectable at all.\n *\n * Reading and writing the record is kept apart from acting on it\n * (`./recover.ts`) because a corrupt record has to stay describable: `doctor`\n * reports a `.loam-commit` it cannot parse, and a module that parsed and\n * repaired in one step would have no way to say that without also attempting\n * the repair.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\nimport { repoPath } from \"../../envelope/json.js\";\nimport { isPathInside } from \"../../kernel/path-safety.js\";\nimport { COMMIT_INTENT, writeDurable } from \"../interrupted.js\";\nimport { isDigest, sha256, type StagedWrite } from \"../writes.js\";\nimport { quietRm } from \"../commit.js\";\n\n/** The version-1 record: archive's and unarchive's snapshot-backed journal. */\nconst COMMIT_INTENT_VERSION = 1;\n\nexport interface CommitIntentFile {\n /** Docs-repo-relative path, forward slashes. */\n path: string;\n /** sha256 of the bytes before the swap; null when the swap creates the file. */\n before: string | null;\n /** sha256 of the bytes the swap writes; null when the swap deletes the file. */\n after: string | null;\n}\n\nexport interface CommitIntent {\n version: number;\n /** Which command was committing — the sentence a human reads, not a branch. */\n command: \"archive\" | \"unarchive\";\n /**\n * The state a repair returns the docs to. `archive` undoes (its rollback path\n * would have restored the pre-images anyway); `unarchive` finishes (its target\n * bytes ARE the pre-images, and the merged text it was replacing is recorded\n * nowhere else).\n */\n restore: \"before\" | \"after\";\n pid: number;\n host: string;\n at: string;\n feature: string;\n /** Docs-repo-relative feature directory the commit's final move takes FROM. */\n moveFrom: string;\n /** …and TO. Both landed ⇒ every earlier step landed too. */\n moveTo: string;\n files: CommitIntentFile[];\n}\n\n/**\n * Write the intent record and get it onto the disk before the first swap.\n *\n * fsync, not just write — through `writeDurable`, whose doc says why: the\n * point of the record is to survive the crash that interrupts the commit.\n */\nexport async function writeCommitIntent(\n docsDir: string,\n spec: {\n command: CommitIntent[\"command\"];\n restore: CommitIntent[\"restore\"];\n feature: string;\n moveFrom: string;\n moveTo: string;\n },\n staged: StagedWrite[],\n): Promise<void> {\n const intent: CommitIntent = {\n version: COMMIT_INTENT_VERSION,\n command: spec.command,\n restore: spec.restore,\n pid: process.pid,\n host: hostname(),\n at: new Date().toISOString(),\n feature: spec.feature,\n moveFrom: repoPath(docsDir, spec.moveFrom),\n moveTo: repoPath(docsDir, spec.moveTo),\n files: staged.map((s) => ({\n path: repoPath(docsDir, s.write.path),\n before: s.before === null ? null : sha256(s.before),\n after: s.content === null ? null : sha256(s.content),\n })),\n };\n await writeDurable(join(docsDir, COMMIT_INTENT), JSON.stringify(intent, null, 2) + \"\\n\");\n}\n\n/** The commit finished (or was rolled back): there is nothing left to recover from. */\nexport async function clearCommitIntent(docsDir: string): Promise<void> {\n await quietRm(join(docsDir, COMMIT_INTENT));\n}\n\n/** The intent record as written, or null when it is absent or unreadable. */\nexport async function readCommitIntent(docsDir: string): Promise<CommitIntent | null> {\n const path = join(docsDir, COMMIT_INTENT);\n if (!existsSync(path)) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(await readFile(path, \"utf8\"));\n } catch {\n return null;\n }\n const i = parsed as Partial<CommitIntent>;\n if (i === null || typeof i !== \"object\") return null;\n if (i.version !== COMMIT_INTENT_VERSION) return null;\n if (i.command !== \"archive\" && i.command !== \"unarchive\") return null;\n if (i.restore !== \"before\" && i.restore !== \"after\") return null;\n if (typeof i.feature !== \"string\" || typeof i.moveFrom !== \"string\" || typeof i.moveTo !== \"string\") return null;\n if (!Array.isArray(i.files)) return null;\n // The journal is untrusted input, and recovery writes over what it names —\n // the same containment the version-2 reader enforces, because an entry\n // escaping the docs repo would turn repair into an arbitrary write.\n const inside = (rel: string): boolean => isPathInside(join(docsDir), join(docsDir, ...rel.split(\"/\")));\n if (!inside(i.moveFrom) || !inside(i.moveTo)) return null;\n for (const f of i.files) {\n if (typeof f?.path !== \"string\") return null;\n if (!isDigestOrNull(f.before) || !isDigestOrNull(f.after)) return null;\n if (!inside(f.path)) return null;\n }\n return i as CommitIntent;\n}\n\nfunction isDigestOrNull(value: unknown): boolean {\n return value === null || isDigest(value);\n}\n\n"]}
@@ -10,6 +10,12 @@
10
10
  * way, a file in NEITHER recorded state was written by somebody else since,
11
11
  * so recovery refuses and names it rather than choosing which of two truths
12
12
  * to destroy.
13
+ *
14
+ * The journaled writers reach it through `openCommitWindow` below — the lock,
15
+ * then this — so the two refusals that can keep a commit window shut are
16
+ * spelled once rather than once per writer. Archive and unarchive are the
17
+ * exception: they call it directly, inside their plan, under a lock they took
18
+ * themselves, and throw its refusal to a handler that maps it.
13
19
  */
14
20
  import { existsSync } from "node:fs";
15
21
  import { readFile, rm } from "node:fs/promises";
@@ -17,8 +23,9 @@ import { dirname, join } from "node:path";
17
23
  import { repoPath } from "../../envelope/json.js";
18
24
  import { sha256 } from "../writes.js";
19
25
  import { atomicWrite, quietRmdir } from "../commit.js";
20
- import { SNAPSHOT_DIR } from "../snapshot.js";
26
+ import { SNAPSHOT_DIR } from "../snapshot/manifest.js";
21
27
  import { COMMIT_INTENT, InterruptedCommitError, sweepPidTemps, } from "../interrupted.js";
28
+ import { acquireDocsLock, acquireDocsLockWaiting, DocsBusyError, LOCK_WAIT_MS } from "../lock.js";
22
29
  import { readTxnIntent } from "../txn/journal.js";
23
30
  import { recoverForward } from "../txn/forward.js";
24
31
  import { clearCommitIntent, readCommitIntent, } from "./intent.js";
@@ -123,4 +130,56 @@ async function finishRecovery(docsDir, intent) {
123
130
  await sweepPidTemps(intent.files.map((f) => dirname(join(docsDir, ...f.path.split("/")))), intent.pid);
124
131
  await clearCommitIntent(docsDir);
125
132
  }
133
+ /**
134
+ * Open a writer's commit window: take the lock in `root` — the docs repo, or
135
+ * gherkin's emission root in the service repo — then refuse or repair a
136
+ * predecessor's interrupted commit before this run reads a byte.
137
+ *
138
+ * `waitMs` is how long to sit out a live holder before answering `docs-busy`,
139
+ * and 0 refuses on first contact; `acquireDocsLockWaiting` says which writers
140
+ * wait and why archive does not.
141
+ *
142
+ * Each writer here used to spell this sequence out by hand, and the copy in
143
+ * `verify` once let its recovery refusal keep the lock — one bad answer set
144
+ * became a wedged docs repo for everyone else. So the refusal releases HERE,
145
+ * before it is returned, and an error that is neither refusal releases before
146
+ * it propagates.
147
+ */
148
+ export async function openCommitWindow(root, waitMs) {
149
+ let release;
150
+ try {
151
+ release = waitMs > 0 ? await acquireDocsLockWaiting(root, waitMs) : await acquireDocsLock(root);
152
+ }
153
+ catch (err) {
154
+ if (!(err instanceof DocsBusyError))
155
+ throw err;
156
+ return { ok: false, code: "docs-busy", message: err.message };
157
+ }
158
+ try {
159
+ return { ok: true, recovered: await recoverInterruptedCommit(root), release };
160
+ }
161
+ catch (err) {
162
+ await release();
163
+ if (!(err instanceof InterruptedCommitError))
164
+ throw err;
165
+ return { ok: false, code: "commit-interrupted", message: err.message };
166
+ }
167
+ }
168
+ /**
169
+ * Recover a predecessor's interrupted commit NOW and hand the lock straight
170
+ * back — for a writer whose slow half runs UNLOCKED over the very bytes a
171
+ * half-commit corrupts (vouch's verification, gherkin's reconcile); each says
172
+ * at its call site why recovery inside its own commit window comes too late.
173
+ * Cheap when there is nothing to do: no journal, no lock, no I/O beyond one
174
+ * existsSync.
175
+ */
176
+ export async function recoverIfJournaled(root) {
177
+ if (!existsSync(join(root, COMMIT_INTENT)))
178
+ return { ok: true, recovered: null };
179
+ const opened = await openCommitWindow(root, LOCK_WAIT_MS);
180
+ if (!opened.ok)
181
+ return opened;
182
+ await opened.release();
183
+ return { ok: true, recovered: opened.recovered };
184
+ }
126
185
  //# sourceMappingURL=recover.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"recover.js","sourceRoot":"","sources":["../../../../src/core/staging/recovery/recover.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Refuse or repair an interrupted commit, before this run reads anything.\n *\n * Split from `./intent.ts` because this is the only module on the write path\n * that writes bytes nobody asked for in this run. Two journal versions, two\n * doctrines, one dispatch: a version-1 record (archive/unarchive) is repaired\n * BACKWARD from the snapshot pre-images, each verified against the digest the\n * record captured; a version-2 record is handed to `txn/forward.ts`, which\n * rolls FORWARD from the staged temps under the same verification. Either\n * way, a file in NEITHER recorded state was written by somebody else since,\n * so recovery refuses and names it rather than choosing which of two truths\n * to destroy.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile, rm } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { repoPath } from \"../../envelope/json.js\";\nimport { sha256 } from \"../writes.js\";\nimport { atomicWrite, quietRmdir } from \"../commit.js\";\nimport { SNAPSHOT_DIR } from \"../snapshot.js\";\nimport {\n COMMIT_INTENT,\n type CommitRecovery,\n InterruptedCommitError,\n sweepPidTemps,\n} from \"../interrupted.js\";\nimport { readTxnIntent } from \"../txn/journal.js\";\nimport { recoverForward } from \"../txn/forward.js\";\nimport {\n clearCommitIntent,\n readCommitIntent,\n type CommitIntent,\n type CommitIntentFile,\n} from \"./intent.js\";\n\n/**\n * Refuse or repair before this run reads anything. Returns null when there is\n * nothing to recover.\n *\n * Repair is only ever the bytes the interrupted run already had on disk: each\n * file still in the wrong state is rewritten from the snapshot pre-image and\n * only after that pre-image's own sha256 matches the digest the record captured\n * before the crash. A file in NEITHER state was written by somebody else since,\n * so this refuses and names it rather than choosing which of two truths to\n * destroy — the same doctrine `rollbackStaged` follows when it leaves a file as\n * found.\n */\nexport async function recoverInterruptedCommit(docsDir: string): Promise<CommitRecovery | null> {\n const path = join(docsDir, COMMIT_INTENT);\n if (!existsSync(path)) return null;\n const intent = await readCommitIntent(docsDir);\n if (intent === null) {\n // Version 2 — the smaller transaction's journal. Same file, different\n // recovery: forward from the staged temps, in txn/forward.ts.\n const txn = await readTxnIntent(docsDir);\n if (txn !== null) return recoverForward(docsDir, txn);\n throw new InterruptedCommitError(\n `${repoPath(docsDir, path)} records a commit this loam cannot read, so it cannot tell a half-merged docs repo ` +\n `from a healthy one. Check the living docs against version control, delete ${COMMIT_INTENT}, then re-run.`,\n );\n }\n const moveFrom = join(docsDir, ...intent.moveFrom.split(\"/\"));\n const moveTo = join(docsDir, ...intent.moveTo.split(\"/\"));\n // The move is the commit's LAST step. If it landed, every swap before it did\n // too, and only the record's own removal was lost.\n if (existsSync(moveTo) && !existsSync(moveFrom)) {\n await finishRecovery(docsDir, intent);\n return { command: intent.command, feature: intent.feature, outcome: \"completed\", repaired: [] };\n }\n\n const target = (rel: string): string => join(docsDir, ...rel.split(\"/\"));\n const wanted = (f: CommitIntentFile): string | null => (intent.restore === \"before\" ? f.before : f.after);\n const other = (f: CommitIntentFile): string | null => (intent.restore === \"before\" ? f.after : f.before);\n const pending: CommitIntentFile[] = [];\n const unknown: string[] = [];\n for (const f of intent.files) {\n const file = target(f.path);\n const now = existsSync(file) ? sha256(await readFile(file)) : null;\n if (now === wanted(f)) continue;\n if (now === other(f)) pending.push(f);\n else unknown.push(f.path);\n }\n if (unknown.length > 0) {\n throw new InterruptedCommitError(\n `a \\`loam ${intent.command} ${intent.feature}\\` was interrupted mid-commit (pid ${intent.pid} on ${intent.host}, ` +\n `${intent.at}) and ${unknown.length} of the file(s) it was writing now hold bytes that are neither what it ` +\n `found nor what it wrote — somebody edited them since: ${unknown.join(\", \")}. loam will not choose between ` +\n `those two truths. Reconcile them against version control, delete ${COMMIT_INTENT}, then re-run.`,\n );\n }\n if (pending.length === 0) {\n await finishRecovery(docsDir, intent);\n return { command: intent.command, feature: intent.feature, outcome: \"consistent\", repaired: [] };\n }\n\n // The pre-images travel inside the feature directory, so they are at one of\n // the two ends of the move depending on how far the interrupted run got.\n // Deliberately NOT the snapshot's (service, artifact) re-keying: the journal\n // and the snapshot were written seconds apart under the same docs lock, so\n // the journal's literal paths and the pre-image keys cannot have diverged —\n // a service directory moved by hand between the crash and this repair leaves\n // its files \"in neither recorded state\", which is the refusal below, not a\n // resolution question.\n const roots = [join(moveFrom, SNAPSHOT_DIR, \"files\"), join(moveTo, SNAPSHOT_DIR, \"files\")];\n const repaired: string[] = [];\n for (const f of pending) {\n const file = target(f.path);\n const want = wanted(f);\n if (want === null) {\n await rm(file, { force: true });\n await quietRmdir(dirname(file));\n repaired.push(f.path);\n continue;\n }\n const source = roots.map((r) => join(r, ...f.path.split(\"/\"))).find((p) => existsSync(p));\n if (source === undefined) {\n throw new InterruptedCommitError(\n `a \\`loam ${intent.command} ${intent.feature}\\` was interrupted mid-commit and ${f.path} is half-written, but ` +\n `the pre-image that would repair it is gone. Restore ${f.path} from version control, delete ` +\n `${COMMIT_INTENT}, then re-run.`,\n );\n }\n const bytes = await readFile(source);\n if (sha256(bytes) !== want) {\n throw new InterruptedCommitError(\n `a \\`loam ${intent.command} ${intent.feature}\\` was interrupted mid-commit and ${f.path} is half-written, but ` +\n `the pre-image that would repair it no longer matches the digest recorded before the commit — it has been ` +\n `edited since. Restore ${f.path} from version control, delete ${COMMIT_INTENT}, then re-run.`,\n );\n }\n await atomicWrite(file, bytes);\n repaired.push(f.path);\n }\n await finishRecovery(docsDir, intent);\n return { command: intent.command, feature: intent.feature, outcome: \"repaired\", repaired };\n}\n\n/** Drop the record, and (via the shared pid-scoped sweep) the killed run's own temps. */\nasync function finishRecovery(docsDir: string, intent: CommitIntent): Promise<void> {\n await sweepPidTemps(\n intent.files.map((f) => dirname(join(docsDir, ...f.path.split(\"/\")))),\n intent.pid,\n );\n await clearCommitIntent(docsDir);\n}\n\n\n"]}
1
+ {"version":3,"file":"recover.js","sourceRoot":"","sources":["../../../../src/core/staging/recovery/recover.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Refuse or repair an interrupted commit, before this run reads anything.\n *\n * Split from `./intent.ts` because this is the only module on the write path\n * that writes bytes nobody asked for in this run. Two journal versions, two\n * doctrines, one dispatch: a version-1 record (archive/unarchive) is repaired\n * BACKWARD from the snapshot pre-images, each verified against the digest the\n * record captured; a version-2 record is handed to `txn/forward.ts`, which\n * rolls FORWARD from the staged temps under the same verification. Either\n * way, a file in NEITHER recorded state was written by somebody else since,\n * so recovery refuses and names it rather than choosing which of two truths\n * to destroy.\n *\n * The journaled writers reach it through `openCommitWindow` below — the lock,\n * then this — so the two refusals that can keep a commit window shut are\n * spelled once rather than once per writer. Archive and unarchive are the\n * exception: they call it directly, inside their plan, under a lock they took\n * themselves, and throw its refusal to a handler that maps it.\n */\nimport { existsSync } from \"node:fs\";\nimport { readFile, rm } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { repoPath } from \"../../envelope/json.js\";\nimport { sha256 } from \"../writes.js\";\nimport { atomicWrite, quietRmdir } from \"../commit.js\";\nimport { SNAPSHOT_DIR } from \"../snapshot/manifest.js\";\nimport {\n COMMIT_INTENT,\n type CommitRecovery,\n InterruptedCommitError,\n sweepPidTemps,\n} from \"../interrupted.js\";\nimport { acquireDocsLock, acquireDocsLockWaiting, DocsBusyError, LOCK_WAIT_MS } from \"../lock.js\";\nimport { readTxnIntent } from \"../txn/journal.js\";\nimport { recoverForward } from \"../txn/forward.js\";\nimport {\n clearCommitIntent,\n readCommitIntent,\n type CommitIntent,\n type CommitIntentFile,\n} from \"./intent.js\";\n\n/**\n * Refuse or repair before this run reads anything. Returns null when there is\n * nothing to recover.\n *\n * Repair is only ever the bytes the interrupted run already had on disk: each\n * file still in the wrong state is rewritten from the snapshot pre-image and\n * only after that pre-image's own sha256 matches the digest the record captured\n * before the crash. A file in NEITHER state was written by somebody else since,\n * so this refuses and names it rather than choosing which of two truths to\n * destroy — the same doctrine `rollbackStaged` follows when it leaves a file as\n * found.\n */\nexport async function recoverInterruptedCommit(docsDir: string): Promise<CommitRecovery | null> {\n const path = join(docsDir, COMMIT_INTENT);\n if (!existsSync(path)) return null;\n const intent = await readCommitIntent(docsDir);\n if (intent === null) {\n // Version 2 — the smaller transaction's journal. Same file, different\n // recovery: forward from the staged temps, in txn/forward.ts.\n const txn = await readTxnIntent(docsDir);\n if (txn !== null) return recoverForward(docsDir, txn);\n throw new InterruptedCommitError(\n `${repoPath(docsDir, path)} records a commit this loam cannot read, so it cannot tell a half-merged docs repo ` +\n `from a healthy one. Check the living docs against version control, delete ${COMMIT_INTENT}, then re-run.`,\n );\n }\n const moveFrom = join(docsDir, ...intent.moveFrom.split(\"/\"));\n const moveTo = join(docsDir, ...intent.moveTo.split(\"/\"));\n // The move is the commit's LAST step. If it landed, every swap before it did\n // too, and only the record's own removal was lost.\n if (existsSync(moveTo) && !existsSync(moveFrom)) {\n await finishRecovery(docsDir, intent);\n return { command: intent.command, feature: intent.feature, outcome: \"completed\", repaired: [] };\n }\n\n const target = (rel: string): string => join(docsDir, ...rel.split(\"/\"));\n const wanted = (f: CommitIntentFile): string | null => (intent.restore === \"before\" ? f.before : f.after);\n const other = (f: CommitIntentFile): string | null => (intent.restore === \"before\" ? f.after : f.before);\n const pending: CommitIntentFile[] = [];\n const unknown: string[] = [];\n for (const f of intent.files) {\n const file = target(f.path);\n const now = existsSync(file) ? sha256(await readFile(file)) : null;\n if (now === wanted(f)) continue;\n if (now === other(f)) pending.push(f);\n else unknown.push(f.path);\n }\n if (unknown.length > 0) {\n throw new InterruptedCommitError(\n `a \\`loam ${intent.command} ${intent.feature}\\` was interrupted mid-commit (pid ${intent.pid} on ${intent.host}, ` +\n `${intent.at}) and ${unknown.length} of the file(s) it was writing now hold bytes that are neither what it ` +\n `found nor what it wrote — somebody edited them since: ${unknown.join(\", \")}. loam will not choose between ` +\n `those two truths. Reconcile them against version control, delete ${COMMIT_INTENT}, then re-run.`,\n );\n }\n if (pending.length === 0) {\n await finishRecovery(docsDir, intent);\n return { command: intent.command, feature: intent.feature, outcome: \"consistent\", repaired: [] };\n }\n\n // The pre-images travel inside the feature directory, so they are at one of\n // the two ends of the move depending on how far the interrupted run got.\n // Deliberately NOT the snapshot's (service, artifact) re-keying: the journal\n // and the snapshot were written seconds apart under the same docs lock, so\n // the journal's literal paths and the pre-image keys cannot have diverged —\n // a service directory moved by hand between the crash and this repair leaves\n // its files \"in neither recorded state\", which is the refusal below, not a\n // resolution question.\n const roots = [join(moveFrom, SNAPSHOT_DIR, \"files\"), join(moveTo, SNAPSHOT_DIR, \"files\")];\n const repaired: string[] = [];\n for (const f of pending) {\n const file = target(f.path);\n const want = wanted(f);\n if (want === null) {\n await rm(file, { force: true });\n await quietRmdir(dirname(file));\n repaired.push(f.path);\n continue;\n }\n const source = roots.map((r) => join(r, ...f.path.split(\"/\"))).find((p) => existsSync(p));\n if (source === undefined) {\n throw new InterruptedCommitError(\n `a \\`loam ${intent.command} ${intent.feature}\\` was interrupted mid-commit and ${f.path} is half-written, but ` +\n `the pre-image that would repair it is gone. Restore ${f.path} from version control, delete ` +\n `${COMMIT_INTENT}, then re-run.`,\n );\n }\n const bytes = await readFile(source);\n if (sha256(bytes) !== want) {\n throw new InterruptedCommitError(\n `a \\`loam ${intent.command} ${intent.feature}\\` was interrupted mid-commit and ${f.path} is half-written, but ` +\n `the pre-image that would repair it no longer matches the digest recorded before the commit — it has been ` +\n `edited since. Restore ${f.path} from version control, delete ${COMMIT_INTENT}, then re-run.`,\n );\n }\n await atomicWrite(file, bytes);\n repaired.push(f.path);\n }\n await finishRecovery(docsDir, intent);\n return { command: intent.command, feature: intent.feature, outcome: \"repaired\", repaired };\n}\n\n/** Drop the record, and (via the shared pid-scoped sweep) the killed run's own temps. */\nasync function finishRecovery(docsDir: string, intent: CommitIntent): Promise<void> {\n await sweepPidTemps(\n intent.files.map((f) => dirname(join(docsDir, ...f.path.split(\"/\")))),\n intent.pid,\n );\n await clearCommitIntent(docsDir);\n}\n\n/** The lock is held and a predecessor's interrupted commit is resolved: this run may read, then write. */\nexport interface OpenCommitWindow {\n ok: true;\n recovered: CommitRecovery | null;\n /** Idempotent. The caller's one obligation, in a `finally`. */\n release: () => Promise<void>;\n}\n\n/** The window did not open. Nothing was read or written, and no lock is held. */\nexport interface CommitWindowRefusal {\n ok: false;\n code: \"docs-busy\" | \"commit-interrupted\";\n message: string;\n}\n\n/**\n * Open a writer's commit window: take the lock in `root` — the docs repo, or\n * gherkin's emission root in the service repo — then refuse or repair a\n * predecessor's interrupted commit before this run reads a byte.\n *\n * `waitMs` is how long to sit out a live holder before answering `docs-busy`,\n * and 0 refuses on first contact; `acquireDocsLockWaiting` says which writers\n * wait and why archive does not.\n *\n * Each writer here used to spell this sequence out by hand, and the copy in\n * `verify` once let its recovery refusal keep the lock — one bad answer set\n * became a wedged docs repo for everyone else. So the refusal releases HERE,\n * before it is returned, and an error that is neither refusal releases before\n * it propagates.\n */\nexport async function openCommitWindow(root: string, waitMs: number): Promise<OpenCommitWindow | CommitWindowRefusal> {\n let release: () => Promise<void>;\n try {\n release = waitMs > 0 ? await acquireDocsLockWaiting(root, waitMs) : await acquireDocsLock(root);\n } catch (err) {\n if (!(err instanceof DocsBusyError)) throw err;\n return { ok: false, code: \"docs-busy\", message: err.message };\n }\n try {\n return { ok: true, recovered: await recoverInterruptedCommit(root), release };\n } catch (err) {\n await release();\n if (!(err instanceof InterruptedCommitError)) throw err;\n return { ok: false, code: \"commit-interrupted\", message: err.message };\n }\n}\n\n/**\n * Recover a predecessor's interrupted commit NOW and hand the lock straight\n * back — for a writer whose slow half runs UNLOCKED over the very bytes a\n * half-commit corrupts (vouch's verification, gherkin's reconcile); each says\n * at its call site why recovery inside its own commit window comes too late.\n * Cheap when there is nothing to do: no journal, no lock, no I/O beyond one\n * existsSync.\n */\nexport async function recoverIfJournaled(\n root: string,\n): Promise<{ ok: true; recovered: CommitRecovery | null } | CommitWindowRefusal> {\n if (!existsSync(join(root, COMMIT_INTENT))) return { ok: true, recovered: null };\n const opened = await openCommitWindow(root, LOCK_WAIT_MS);\n if (!opened.ok) return opened;\n await opened.release();\n return { ok: true, recovered: opened.recovered };\n}\n\n"]}
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Where the undo snapshot lives and what its manifest says — the half of
3
+ * `./snapshot.ts` a command can know without being able to take a snapshot.
4
+ *
5
+ * Split from that module when the snapshot became a package of its own: taking
6
+ * a snapshot (and refusing to clobber one) is a commit, and the MCP server's
7
+ * read-only claim is DERIVED from the import graph — a served command whose
8
+ * imports reach `./snapshot.ts` is, by that test, a command that can commit.
9
+ * The layout below is pure naming and shape, and the readers that only need
10
+ * to find a snapshot, or read what one recorded, import it from here.
11
+ */
12
+ import { isUtf8 } from "node:buffer";
13
+ import { readFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { resolvePortableFileInsideLexically } from "../../kernel/path-safety.js";
16
+ import { isRecord } from "../../kernel/records.js";
17
+ import { sha256 } from "../writes.js";
18
+ /** Where archive parks the pre-images, inside the feature directory so they travel with it — `./snapshot.ts`'s module doc says why. */
19
+ export const SNAPSHOT_DIR = ".loam-before";
20
+ export const SNAPSHOT_MANIFEST = "manifest.json";
21
+ /**
22
+ * Bumped only when the layout changes in a way `unarchive` must refuse to guess
23
+ * at. 2 added `before`: a version-1 snapshot says nothing about the bytes it
24
+ * would RESTORE, so an edited pre-image read back as an intact one. 3 re-keys
25
+ * `services/` entries by `(service, artifact)`: a literal path pins the service
26
+ * directory where it stood on the day of the archive, and once the subsystem
27
+ * tree lets it move — re-cutting is expected to be routine — an undo addressed
28
+ * by path restores into a location that is gone. The service id never moves.
29
+ */
30
+ export const SNAPSHOT_VERSION = 3;
31
+ /**
32
+ * Version 2 is still READ, forever: version-2 snapshots already sit inside
33
+ * archived features, which carry them across loam upgrades, and each must keep
34
+ * restoring — and keep being graded for staleness by a retried archive — under
35
+ * its own literal-path rules. Only version 1 is refused (no restore digests).
36
+ */
37
+ export const SNAPSHOT_COMPAT_VERSION = 2;
38
+ export function snapshotDir(featureDir) {
39
+ return join(featureDir, SNAPSHOT_DIR);
40
+ }
41
+ /**
42
+ * The checklist digest an archived feature's snapshot captured as it merged,
43
+ * or null when it carries none.
44
+ *
45
+ * It lives in the manifest because the manifest is the one file `archive`
46
+ * already writes into the feature at the moment of the merge, travels with it
47
+ * into `features/archive/`, and is taken away by `unarchive` — so a feature
48
+ * archived again gets a fresh one, captured against what it then asked. A
49
+ * frozen verification record cannot be judged against a checklist re-derived
50
+ * after the merge (the merge moved the feature's operations into the living
51
+ * contract, so the derivation can only mismatch); this is the question as it
52
+ * stood when the feature shipped, which is what `loam gate --since` holds the
53
+ * record to. Here rather than beside the writer for the reason this module
54
+ * exists: the gate is a read-only command, served over MCP as one.
55
+ *
56
+ * Tolerant to the last byte, and every failure is "none captured": a manifest
57
+ * an older loam wrote, a feature archived with no record, a manifest that is
58
+ * missing or does not parse. The reader that grades on it reads null as the
59
+ * frozen history it always read, and says so — it never refuses a deploy
60
+ * question over bookkeeping.
61
+ */
62
+ export async function readSnapshotChecklist(featureDir) {
63
+ const manifest = await manifestRecord(featureDir);
64
+ if (manifest === null)
65
+ return null;
66
+ const { checklist } = manifest;
67
+ return typeof checklist === "string" && checklist !== "" ? checklist : null;
68
+ }
69
+ /**
70
+ * The bytes one service artifact held the moment this feature merged — the
71
+ * pre-image the snapshot parked under `files/` — or one of the two answers
72
+ * that say the merge took nothing out of it ({@link SnapshotPreImage}), or
73
+ * null when there is nothing to trust.
74
+ *
75
+ * Read for the questions only the pre-image can answer, both asked by the
76
+ * retired-operation revival check (core/coherence/retired/): what a merge TOOK
77
+ * that its delta never spelled — a channel-slot removal in an asyncapi delta is
78
+ * a bare marker, while the messages it retired with the channel are written in
79
+ * the living contract it merged into and nowhere else — and whether a removal
80
+ * marker took anything at all, since the merge deletes only what it finds at
81
+ * the marker's slot. Here, beside `readSnapshotChecklist`, for that reader's
82
+ * reason: the readers are read-only commands, and this module is the half of
83
+ * the snapshot they may import.
84
+ *
85
+ * The entry is found by `(service, artifact)` on a version-3 manifest — the
86
+ * key that survives a subsystem re-cut — and by the literal path's last two
87
+ * segments on a version-2 one, which recorded nothing else. Tolerant to the
88
+ * last byte, like the checklist reader: no manifest, a layout this loam would
89
+ * have to guess at, no row for the artifact while some row cannot be read (it
90
+ * might have been this one's, so absence cannot be concluded past it), a path
91
+ * that does not stay inside `files/`, a pre-image that no longer hashes to the digest
92
+ * the manifest recorded for it, or bytes that are not UTF-8 — every one is
93
+ * null, and the caller grades without it. An edited pre-image is refused
94
+ * rather than read for the reason `unarchive` refuses to restore one: its
95
+ * bytes are no longer what the merge saw.
96
+ */
97
+ export async function readSnapshotPreImage(featureDir, key) {
98
+ const manifest = await manifestRecord(featureDir);
99
+ if (manifest === null || !Array.isArray(manifest.files))
100
+ return null;
101
+ if (manifest.version !== SNAPSHOT_VERSION && manifest.version !== SNAPSHOT_COMPAT_VERSION)
102
+ return null;
103
+ // `Array.isArray` narrows to `any[]`; naming the element type keeps that
104
+ // `any` from spreading past the row being read (snapshot.ts's reason).
105
+ const rows = manifest.files;
106
+ const entry = rows.find((row) => isRecord(row) && entryIs(row, key));
107
+ if (entry === undefined) {
108
+ // Absence is an answer only when every row could be read: a row that is
109
+ // not a record with a path might have been this artifact's.
110
+ return rows.every((row) => isRecord(row) && typeof row.path === "string") ? { kind: "unwritten" } : null;
111
+ }
112
+ if (!isRecord(entry) || typeof entry.path !== "string")
113
+ return null;
114
+ if (entry.existed === false && entry.before === null)
115
+ return { kind: "created" };
116
+ if (typeof entry.before !== "string")
117
+ return null;
118
+ let bytes;
119
+ try {
120
+ // The manifest is data and this path is one we go and read: the lexical
121
+ // containment `unarchive` and the staleness check apply to the same field.
122
+ bytes = await readFile(resolvePortableFileInsideLexically(join(snapshotDir(featureDir), "files"), entry.path, "snapshot pre-image"));
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ if (sha256(bytes) !== entry.before || !isUtf8(bytes))
128
+ return null;
129
+ return { kind: "held", text: bytes.toString("utf8") };
130
+ }
131
+ /** Does this manifest row record `key`'s artifact? The version-3 key when it has one, the literal path otherwise. */
132
+ function entryIs(row, key) {
133
+ if (typeof row.service === "string" && typeof row.artifact === "string") {
134
+ return row.service === key.service && row.artifact === key.artifact;
135
+ }
136
+ if (typeof row.path !== "string")
137
+ return false;
138
+ const parts = row.path.split("/");
139
+ return parts.length >= 3 && parts[0] === "services" && parts.at(-2) === key.service && parts.at(-1) === key.artifact;
140
+ }
141
+ /** The manifest as a record, or null — the one tolerant read behind both readers above. */
142
+ async function manifestRecord(featureDir) {
143
+ let manifest;
144
+ try {
145
+ manifest = JSON.parse(await readFile(join(snapshotDir(featureDir), SNAPSHOT_MANIFEST), "utf8"));
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ return isRecord(manifest) ? manifest : null;
151
+ }
152
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../../../../src/core/staging/snapshot/manifest.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Where the undo snapshot lives and what its manifest says — the half of\n * `./snapshot.ts` a command can know without being able to take a snapshot.\n *\n * Split from that module when the snapshot became a package of its own: taking\n * a snapshot (and refusing to clobber one) is a commit, and the MCP server's\n * read-only claim is DERIVED from the import graph — a served command whose\n * imports reach `./snapshot.ts` is, by that test, a command that can commit.\n * The layout below is pure naming and shape, and the readers that only need\n * to find a snapshot, or read what one recorded, import it from here.\n */\nimport { isUtf8 } from \"node:buffer\";\nimport { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { resolvePortableFileInsideLexically } from \"../../kernel/path-safety.js\";\nimport { isRecord } from \"../../kernel/records.js\";\nimport { sha256 } from \"../writes.js\";\n\n/** Where archive parks the pre-images, inside the feature directory so they travel with it — `./snapshot.ts`'s module doc says why. */\nexport const SNAPSHOT_DIR = \".loam-before\";\nexport const SNAPSHOT_MANIFEST = \"manifest.json\";\n/**\n * Bumped only when the layout changes in a way `unarchive` must refuse to guess\n * at. 2 added `before`: a version-1 snapshot says nothing about the bytes it\n * would RESTORE, so an edited pre-image read back as an intact one. 3 re-keys\n * `services/` entries by `(service, artifact)`: a literal path pins the service\n * directory where it stood on the day of the archive, and once the subsystem\n * tree lets it move — re-cutting is expected to be routine — an undo addressed\n * by path restores into a location that is gone. The service id never moves.\n */\nexport const SNAPSHOT_VERSION = 3;\n/**\n * Version 2 is still READ, forever: version-2 snapshots already sit inside\n * archived features, which carry them across loam upgrades, and each must keep\n * restoring — and keep being graded for staleness by a retried archive — under\n * its own literal-path rules. Only version 1 is refused (no restore digests).\n */\nexport const SNAPSHOT_COMPAT_VERSION = 2;\n\nexport interface SnapshotEntry {\n /**\n * Docs-repo-relative path, forward slashes, AS ARCHIVED. Load-bearing even\n * beside the key below: it names the pre-image under `.loam-before/files/`,\n * and it is the literal fallback when the id stops being enumerated.\n */\n path: string;\n /**\n * For a `services/` entry: the service id (the leaf directory name — the one\n * thing a re-cut never changes) and the artifact's path inside its directory,\n * resolved through the CURRENT enumeration at restore time. Absent on\n * landscape and `features/` entries, which do not move.\n */\n service?: string;\n artifact?: string;\n /** False when archive CREATED the file: restoring it means deleting it again. */\n existed: boolean;\n /** sha256 of what archive wrote, so unarchive can tell its own merge from later edits. */\n after: string;\n /**\n * sha256 of the pre-image beside this manifest — the bytes `unarchive` will\n * RESTORE. Null when archive created the file (there is no pre-image).\n * `after` only describes what archive wrote; while nothing described the\n * restore source, a pre-image edited in the archived feature directory — a\n * bad merge, a rebase, a stray editor — was written back verbatim and every\n * loam surface certified the result.\n */\n before: string | null;\n}\n\nexport interface SnapshotManifest {\n version: number;\n feature: string;\n dirName: string;\n archivedAt: string;\n /**\n * The digest of the checklist a fresh `loam verify` derived for the feature\n * just before this merge — present when the feature carried a record then,\n * absent otherwise and in every manifest an older loam wrote. Additive, so\n * the layout version does not move: `unarchive` never reads it, and a reader\n * that predates it ignores it. See {@link readSnapshotChecklist}.\n */\n checklist?: string;\n files: SnapshotEntry[];\n}\n\nexport function snapshotDir(featureDir: string): string {\n return join(featureDir, SNAPSHOT_DIR);\n}\n\n/**\n * The checklist digest an archived feature's snapshot captured as it merged,\n * or null when it carries none.\n *\n * It lives in the manifest because the manifest is the one file `archive`\n * already writes into the feature at the moment of the merge, travels with it\n * into `features/archive/`, and is taken away by `unarchive` — so a feature\n * archived again gets a fresh one, captured against what it then asked. A\n * frozen verification record cannot be judged against a checklist re-derived\n * after the merge (the merge moved the feature's operations into the living\n * contract, so the derivation can only mismatch); this is the question as it\n * stood when the feature shipped, which is what `loam gate --since` holds the\n * record to. Here rather than beside the writer for the reason this module\n * exists: the gate is a read-only command, served over MCP as one.\n *\n * Tolerant to the last byte, and every failure is \"none captured\": a manifest\n * an older loam wrote, a feature archived with no record, a manifest that is\n * missing or does not parse. The reader that grades on it reads null as the\n * frozen history it always read, and says so — it never refuses a deploy\n * question over bookkeeping.\n */\nexport async function readSnapshotChecklist(featureDir: string): Promise<string | null> {\n const manifest = await manifestRecord(featureDir);\n if (manifest === null) return null;\n const { checklist } = manifest;\n return typeof checklist === \"string\" && checklist !== \"\" ? checklist : null;\n}\n\n/**\n * What a snapshot says about one service artifact as this feature's merge found\n * it. `held` is the pre-image itself. The other two are answers too, and the\n * same one for the question the reader asks — what did the merge take OUT of\n * the artifact: nothing. `created` is a row archive wrote for a file it\n * created (there was nothing before it); `unwritten` is an intact manifest\n * with no row for the artifact at all — archive records every file it writes,\n * so the artifact is what the merge found. Only the EVENT merge leaves a\n * contract unwritten: it returns no text when it changes nothing\n * (asyncapi/merge/merge.ts, `text: null` — a delta whose every marker missed\n * its target is exactly that). The OpenAPI merge returns text whenever the\n * delta has a path entry, so its no-op is an ordinary row whose pre-image\n * equals what it wrote — `held`, which answers the same question the same\n * way: every marker misses against it again.\n */\nexport type SnapshotPreImage =\n | { kind: \"held\"; text: string }\n | { kind: \"created\" }\n | { kind: \"unwritten\" };\n\n/**\n * The bytes one service artifact held the moment this feature merged — the\n * pre-image the snapshot parked under `files/` — or one of the two answers\n * that say the merge took nothing out of it ({@link SnapshotPreImage}), or\n * null when there is nothing to trust.\n *\n * Read for the questions only the pre-image can answer, both asked by the\n * retired-operation revival check (core/coherence/retired/): what a merge TOOK\n * that its delta never spelled — a channel-slot removal in an asyncapi delta is\n * a bare marker, while the messages it retired with the channel are written in\n * the living contract it merged into and nowhere else — and whether a removal\n * marker took anything at all, since the merge deletes only what it finds at\n * the marker's slot. Here, beside `readSnapshotChecklist`, for that reader's\n * reason: the readers are read-only commands, and this module is the half of\n * the snapshot they may import.\n *\n * The entry is found by `(service, artifact)` on a version-3 manifest — the\n * key that survives a subsystem re-cut — and by the literal path's last two\n * segments on a version-2 one, which recorded nothing else. Tolerant to the\n * last byte, like the checklist reader: no manifest, a layout this loam would\n * have to guess at, no row for the artifact while some row cannot be read (it\n * might have been this one's, so absence cannot be concluded past it), a path\n * that does not stay inside `files/`, a pre-image that no longer hashes to the digest\n * the manifest recorded for it, or bytes that are not UTF-8 — every one is\n * null, and the caller grades without it. An edited pre-image is refused\n * rather than read for the reason `unarchive` refuses to restore one: its\n * bytes are no longer what the merge saw.\n */\nexport async function readSnapshotPreImage(\n featureDir: string,\n key: { service: string; artifact: string },\n): Promise<SnapshotPreImage | null> {\n const manifest = await manifestRecord(featureDir);\n if (manifest === null || !Array.isArray(manifest.files)) return null;\n if (manifest.version !== SNAPSHOT_VERSION && manifest.version !== SNAPSHOT_COMPAT_VERSION) return null;\n // `Array.isArray` narrows to `any[]`; naming the element type keeps that\n // `any` from spreading past the row being read (snapshot.ts's reason).\n const rows: unknown[] = manifest.files;\n const entry = rows.find((row) => isRecord(row) && entryIs(row, key));\n if (entry === undefined) {\n // Absence is an answer only when every row could be read: a row that is\n // not a record with a path might have been this artifact's.\n return rows.every((row) => isRecord(row) && typeof row.path === \"string\") ? { kind: \"unwritten\" } : null;\n }\n if (!isRecord(entry) || typeof entry.path !== \"string\") return null;\n if (entry.existed === false && entry.before === null) return { kind: \"created\" };\n if (typeof entry.before !== \"string\") return null;\n let bytes: Buffer;\n try {\n // The manifest is data and this path is one we go and read: the lexical\n // containment `unarchive` and the staleness check apply to the same field.\n bytes = await readFile(\n resolvePortableFileInsideLexically(join(snapshotDir(featureDir), \"files\"), entry.path, \"snapshot pre-image\"),\n );\n } catch {\n return null;\n }\n if (sha256(bytes) !== entry.before || !isUtf8(bytes)) return null;\n return { kind: \"held\", text: bytes.toString(\"utf8\") };\n}\n\n/** Does this manifest row record `key`'s artifact? The version-3 key when it has one, the literal path otherwise. */\nfunction entryIs(row: Record<string, unknown>, key: { service: string; artifact: string }): boolean {\n if (typeof row.service === \"string\" && typeof row.artifact === \"string\") {\n return row.service === key.service && row.artifact === key.artifact;\n }\n if (typeof row.path !== \"string\") return false;\n const parts = row.path.split(\"/\");\n return parts.length >= 3 && parts[0] === \"services\" && parts.at(-2) === key.service && parts.at(-1) === key.artifact;\n}\n\n/** The manifest as a record, or null — the one tolerant read behind both readers above. */\nasync function manifestRecord(featureDir: string): Promise<Record<string, unknown> | null> {\n let manifest: unknown;\n try {\n manifest = JSON.parse(await readFile(join(snapshotDir(featureDir), SNAPSHOT_MANIFEST), \"utf8\"));\n } catch {\n return null;\n }\n return isRecord(manifest) ? manifest : null;\n}\n"]}