@mnstry/atelier 0.2.0-alpha.5 → 0.2.0-alpha.8

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 (383) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +86 -25
  3. package/SECURITY.md +6 -1
  4. package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
  5. package/contracts/atelier-coauthor.v1.schema.json +163 -0
  6. package/contracts/atelier-decision-request.v1.schema.json +476 -0
  7. package/contracts/atelier-decision-result.v1.schema.json +500 -0
  8. package/contracts/atelier-guide.v1.schema.json +227 -0
  9. package/contracts/atelier-intake.v1.schema.json +151 -0
  10. package/contracts/atelier-migration.v2.schema.json +61 -0
  11. package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
  12. package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
  13. package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
  14. package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
  15. package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
  16. package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
  17. package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
  18. package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
  19. package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
  20. package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
  21. package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
  22. package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
  23. package/contracts/atelier-presentation.v1.schema.json +1293 -0
  24. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  25. package/contracts/atelier-review.v1.schema.json +1601 -0
  26. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  27. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  28. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  29. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  30. package/docs/assurance-controls.md +12 -2
  31. package/docs/atelier-runtime.md +13 -2
  32. package/docs/atelier-sync.md +172 -0
  33. package/docs/blocks/claims.md +28 -18
  34. package/docs/blocks/will-not-do.md +12 -3
  35. package/docs/coauthor-session.md +57 -0
  36. package/docs/connected-composition.md +75 -0
  37. package/docs/decisions.md +167 -0
  38. package/docs/exact-upgrades.md +159 -0
  39. package/docs/extension-pack-lifecycle.md +33 -0
  40. package/docs/guided-upgrades.md +99 -0
  41. package/docs/install.md +65 -9
  42. package/docs/intake-and-guides.md +65 -0
  43. package/docs/integration-and-review-plan.md +409 -0
  44. package/docs/integration-contract-decisions.md +22 -0
  45. package/docs/local-review.md +84 -0
  46. package/docs/local-services.md +227 -0
  47. package/docs/local-state.md +107 -0
  48. package/docs/obsidian-contract.md +687 -0
  49. package/docs/obsidian.md +387 -0
  50. package/docs/portable-presentation.md +328 -0
  51. package/docs/presentation-consumer-boundaries.md +75 -0
  52. package/docs/presentation-review-disposition.md +47 -0
  53. package/docs/project-command-inventory.md +38 -0
  54. package/docs/project-options.md +53 -0
  55. package/docs/release-engineering.md +99 -2
  56. package/docs/review-portability.md +55 -0
  57. package/docs/skill-steward.md +156 -0
  58. package/docs/upgrade.md +70 -2
  59. package/docs/vault-service.md +292 -0
  60. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  61. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  62. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  64. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  65. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  66. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  67. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  69. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  70. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  71. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  72. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  73. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  74. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  75. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  76. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  77. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  78. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  79. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  80. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  81. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  82. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  83. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  84. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  85. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  86. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  87. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  88. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  89. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  90. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  91. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  92. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  93. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  94. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  95. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  96. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  97. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  98. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  99. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  100. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  101. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  102. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  103. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  104. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  105. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  106. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  107. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  108. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  109. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  113. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  114. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  115. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  116. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  121. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  122. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  123. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  125. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  131. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  132. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  133. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  136. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  137. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  138. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  139. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  142. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  143. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  144. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  145. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  146. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  147. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  161. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  162. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  163. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  164. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  169. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  170. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  171. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  172. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  177. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  178. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  179. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  181. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  182. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  183. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  187. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  188. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  189. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  190. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  193. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  194. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  195. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  196. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  197. package/fixtures/obsidian/edits/workspace.json +65 -0
  198. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  199. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  200. package/fixtures/obsidian/materialization/workspace.json +54 -0
  201. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  202. package/fixtures/ui/presentation/pane.v1.json +7 -0
  203. package/fixtures/ui/presentation/reference.v1.json +293 -0
  204. package/package.json +61 -6
  205. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  206. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  207. package/skills/claude/atelier-open/SKILL.md +5 -0
  208. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  209. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  210. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  211. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  212. package/skills/codex/atelier-open/SKILL.md +5 -0
  213. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  214. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  215. package/src/access/preview-enforcer.mjs +84 -0
  216. package/src/boundary/content-rules.mjs +9 -4
  217. package/src/boundary/policy.mjs +74 -42
  218. package/src/cli/project-options.mjs +58 -0
  219. package/src/cli/run.mjs +61 -3
  220. package/src/coauthor/session.mjs +161 -0
  221. package/src/coauthor/store.mjs +173 -0
  222. package/src/collaboration/event-ledger.mjs +16 -33
  223. package/src/collaboration/inspection-bundle.mjs +223 -0
  224. package/src/collaboration/review-contracts.mjs +25 -0
  225. package/src/collaboration/review-store.mjs +305 -0
  226. package/src/commands/coauthor.mjs +26 -0
  227. package/src/commands/config.mjs +6 -1
  228. package/src/commands/extension-pack.mjs +15 -8
  229. package/src/commands/init.mjs +44 -5
  230. package/src/commands/obsidian.mjs +368 -0
  231. package/src/commands/review.mjs +147 -0
  232. package/src/commands/setup.mjs +51 -14
  233. package/src/commands/skills.mjs +199 -0
  234. package/src/commands/sync.mjs +107 -0
  235. package/src/composition/registry.mjs +27 -0
  236. package/src/composition/wire.mjs +122 -0
  237. package/src/contracts/corpus.mjs +35 -0
  238. package/src/decisions/contracts.d.mts +96 -0
  239. package/src/decisions/contracts.mjs +321 -0
  240. package/src/disclosure/content-scan.mjs +13 -0
  241. package/src/export/atelier-export-contract.mjs +16 -1
  242. package/src/extension-packs/lifecycle.mjs +154 -0
  243. package/src/graph/graph.mjs +64 -2
  244. package/src/graph/knowledge-graph.mjs +548 -24
  245. package/src/guides/contracts.mjs +54 -0
  246. package/src/index.mjs +29 -0
  247. package/src/intake/store.mjs +119 -0
  248. package/src/preview/channel.mjs +89 -0
  249. package/src/project/config.mjs +50 -49
  250. package/src/project/durable-state.mjs +161 -0
  251. package/src/project/file-class.mjs +166 -0
  252. package/src/project/private-state.mjs +11 -1
  253. package/src/projection/obsidian/contracts.mjs +571 -0
  254. package/src/projection/obsidian/edits/align.mjs +169 -0
  255. package/src/projection/obsidian/edits/apply.mjs +889 -0
  256. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  257. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  258. package/src/projection/obsidian/edits/index.mjs +19 -0
  259. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  260. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  261. package/src/projection/obsidian/edits/observe.mjs +123 -0
  262. package/src/projection/obsidian/edits/policy.mjs +123 -0
  263. package/src/projection/obsidian/edits/regions.mjs +648 -0
  264. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  265. package/src/projection/obsidian/materialize/index.mjs +7 -0
  266. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  267. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  268. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  269. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  270. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  271. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  272. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  273. package/src/projection/obsidian/proposals/index.mjs +23 -0
  274. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  275. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  276. package/src/projection/obsidian/proposals/router.mjs +168 -0
  277. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  278. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  279. package/src/projection/obsidian/publication/index.mjs +5 -0
  280. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  281. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  282. package/src/projection/obsidian/publication/transport.mjs +139 -0
  283. package/src/projection/obsidian/recovery/index.mjs +6 -0
  284. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  285. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  286. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  287. package/src/projection/obsidian/recovery/store.mjs +234 -0
  288. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  289. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  290. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  291. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  292. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  293. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  294. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  295. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  296. package/src/projection/project.mjs +28 -19
  297. package/src/readiness-protocols/evidence.mjs +282 -0
  298. package/src/readiness-protocols/runtime.mjs +4 -3
  299. package/src/readiness-protocols/source-read.mjs +39 -0
  300. package/src/runtime/git-adapter.mjs +189 -0
  301. package/src/runtime/local-state.mjs +439 -0
  302. package/src/runtime/obsidian/app-capability.mjs +110 -0
  303. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  304. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  305. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  306. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  307. package/src/runtime/obsidian/contributions.mjs +27 -0
  308. package/src/runtime/obsidian/documents.mjs +37 -0
  309. package/src/runtime/obsidian/enablement.mjs +53 -0
  310. package/src/runtime/obsidian/engine.mjs +532 -0
  311. package/src/runtime/obsidian/errors.mjs +14 -0
  312. package/src/runtime/obsidian/extension-points.mjs +103 -0
  313. package/src/runtime/obsidian/index.mjs +34 -0
  314. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  315. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  316. package/src/runtime/obsidian/observation.mjs +158 -0
  317. package/src/runtime/obsidian/opening.mjs +243 -0
  318. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  319. package/src/runtime/obsidian/pipeline.mjs +122 -0
  320. package/src/runtime/obsidian/private-lock.mjs +156 -0
  321. package/src/runtime/obsidian/process-identity.mjs +58 -0
  322. package/src/runtime/obsidian/service-client.mjs +66 -0
  323. package/src/runtime/obsidian/service-main.mjs +89 -0
  324. package/src/runtime/obsidian/service-record.mjs +169 -0
  325. package/src/runtime/obsidian/service-server.mjs +121 -0
  326. package/src/runtime/obsidian/service.mjs +216 -0
  327. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  328. package/src/runtime/obsidian/state-store.mjs +180 -0
  329. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  330. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  331. package/src/runtime/obsidian/watchers.mjs +36 -0
  332. package/src/runtime/repository-observation.mjs +491 -0
  333. package/src/runtime/supervisor.mjs +803 -0
  334. package/src/server/local-sidecar.mjs +44 -0
  335. package/src/server/server.mjs +1 -0
  336. package/src/skills/steward.mjs +780 -0
  337. package/src/ui/presentation/browser.mjs +278 -0
  338. package/src/ui/presentation/contract.mjs +116 -0
  339. package/src/ui/presentation/index.mjs +7 -0
  340. package/src/ui/presentation/native.mjs +153 -0
  341. package/src/ui/presentation/proof.mjs +57 -0
  342. package/src/ui/presentation/schema-check.mjs +30 -0
  343. package/src/ui/presentation/schema.generated.mjs +1371 -0
  344. package/src/ui/presentation/state.mjs +43 -0
  345. package/src/ui/presentation/styles.mjs +67 -0
  346. package/src/ui/presentation/tokens.mjs +74 -0
  347. package/src/ui/presentation/web.mjs +85 -0
  348. package/src/ui/review-page.mjs +32 -0
  349. package/src/upgrade/explanation.mjs +33 -0
  350. package/src/upgrade/provenance.mjs +338 -0
  351. package/src/upgrade/transaction-files.mjs +103 -0
  352. package/src/upgrade/transaction.mjs +475 -0
  353. package/src/upgrade/upgrade.mjs +63 -28
  354. package/src/vault/hosts.mjs +21 -0
  355. package/src/vault/identity.mjs +19 -0
  356. package/src/vault/index.mjs +7 -0
  357. package/src/vault/interface.mjs +11 -0
  358. package/src/vault/metadata.mjs +31 -0
  359. package/src/vault/privacy.mjs +148 -0
  360. package/src/vault/probe.mjs +89 -0
  361. package/src/vault/service.mjs +131 -0
  362. package/src/vault/source.mjs +45 -0
  363. package/src/vault/storage.mjs +23 -0
  364. package/templates/distribution-workspace/.claude/launch.json +6 -2
  365. package/templates/distribution-workspace/gitignore +1 -0
  366. package/templates/distribution-workspace/project/README.md +2 -2
  367. package/templates/external-project-workspace/README.md +45 -0
  368. package/templates/external-project-workspace/answers.example.json +4 -0
  369. package/templates/external-project-workspace/atelier.project.json +40 -0
  370. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  371. package/templates/external-project-workspace/gitignore +8 -0
  372. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  373. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  374. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  375. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  376. package/templates/external-project-workspace/source/README.md +11 -0
  377. package/templates/launch.json +6 -2
  378. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  379. package/templates/private-domain-workspace/domain/README.md +2 -2
  380. package/templates/private-domain-workspace/gitignore +1 -0
  381. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  382. package/templates/shared-project-workspace/gitignore +1 -0
  383. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,156 @@
1
+ import { createHash, randomBytes as cryptoRandomBytes } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import { hostname } from 'node:os'
4
+ import path from 'node:path'
5
+ import { isPendingPrivateWrite, publishPrivateFile } from '../../project/durable-state.mjs'
6
+ import { ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../project/private-state.mjs'
7
+ import { closedObject, isoTime } from './documents.mjs'
8
+ import { ObsidianMaintenanceRefusal } from './errors.mjs'
9
+
10
+ // A private lock between processes of one machine, as a directory of owner
11
+ // tickets:
12
+ //
13
+ // <lock>/000000000007.json the ticket of generation 7: who holds it
14
+ // <lock>/000000000007.released the same bytes: generation 7 was given back
15
+ //
16
+ // Only the highest generation means anything. A ticket is published with a
17
+ // non-overwriting hard link, so of two contenders for generation N+1 exactly
18
+ // one wins; the winner then confirms that nothing higher exists (a contender
19
+ // that decided on an old listing finds the newer ticket and withdraws) and
20
+ // removes every lower generation, so a lock taken on every tick does not grow.
21
+ //
22
+ // Nothing is ever taken from a holder that may still be working. A ticket
23
+ // whose holder did not release it is passed over only with proof:
24
+ //
25
+ // - its process is gone; or
26
+ // - it is a maintenance service that recorded where it answers health, and
27
+ // that address refuses connections or answers as another runtime or PID.
28
+ // A service listens from before its first tick until after its last, so
29
+ // either answer proves the recorded runtime is not ticking.
30
+ //
31
+ // No answer in time proves nothing (a busy holder answers late). A ticket
32
+ // from another machine, an unreadable ticket, an unknown file, or a live
33
+ // process that recorded no health address all need a person: see
34
+ // `inspectPrivateGenerationLock` and docs/local-services.md.
35
+
36
+ export const LOCK_TICKET_SCHEMA = 'atelier-obsidian-lock-ticket/v1'
37
+ export const ENGINE_LOCK_DIRECTORY = path.join('state', 'maintenance', 'engine-lock')
38
+
39
+ const TICKET_NAME = /^(\d{12})\.(json|released)$/
40
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
41
+ const LOOPBACK = new Set(['127.0.0.1', '::1'])
42
+ const MAX_GENERATION = 999999999999
43
+
44
+ export const machineDigest = () => createHash('sha256').update(hostname()).digest('hex')
45
+
46
+ export function isProcessAlive(pid) {
47
+ try { process.kill(pid, 0); return true } catch (error) { return error.code !== 'ESRCH' }
48
+ }
49
+
50
+ function validateTicket(document, workspaceId) {
51
+ closedObject(document, { required: ['schema', 'workspaceId', 'purpose', 'pid', 'machine', 'nonce', 'acquiredAt', 'service'] }, 'invalid-lock-ticket', 'a lock ticket')
52
+ const ok = document.schema === LOCK_TICKET_SCHEMA && document.workspaceId === workspaceId && typeof document.purpose === 'string' && IDENTIFIER.test(document.purpose)
53
+ && Number.isSafeInteger(document.pid) && document.pid > 0 && /^[0-9a-f]{64}$/.test(document.machine) && /^[0-9a-f]{32}$/.test(document.nonce) && typeof document.acquiredAt === 'string'
54
+ if (!ok) throw new ObsidianMaintenanceRefusal('invalid-lock-ticket', 'a lock ticket is malformed or belongs to another workspace')
55
+ if (document.service !== null) {
56
+ closedObject(document.service, { required: ['host', 'port', 'runtimeId'] }, 'invalid-lock-ticket', 'the service of a lock ticket')
57
+ if (!LOOPBACK.has(document.service.host) || !Number.isInteger(document.service.port) || document.service.port < 1 || document.service.port > 65535 || typeof document.service.runtimeId !== 'string' || !IDENTIFIER.test(document.service.runtimeId)) {
58
+ throw new ObsidianMaintenanceRefusal('invalid-lock-ticket', 'the service of a lock ticket is malformed')
59
+ }
60
+ }
61
+ return document
62
+ }
63
+
64
+ // Whether the holder of an unreleased ticket is provably not working any more.
65
+ // `probe` answers { kind: 'refused' | 'health' | ... , body }.
66
+ export function createAbandonmentProof({ probe = null, alive = isProcessAlive, machine = machineDigest(), maxAgeMs = null } = {}) {
67
+ return async function proveAbandoned(ticket, { nowMs } = {}) {
68
+ if (ticket.machine !== machine) return { abandoned: false, reason: 'held-on-another-machine' }
69
+ if (!alive(ticket.pid)) return { abandoned: true, reason: 'holder-process-gone' }
70
+ if (ticket.pid === process.pid) return { abandoned: false, reason: 'held-by-this-process' }
71
+ // A purpose whose holder is bounded in time by construction (a start) may name a maximum age.
72
+ if (maxAgeMs !== null && Number.isFinite(nowMs) && nowMs - Date.parse(ticket.acquiredAt) > maxAgeMs) return { abandoned: true, reason: 'holder-older-than-its-bound' }
73
+ if (ticket.service === null || typeof probe !== 'function') return { abandoned: false, reason: 'live-process-unproven' }
74
+ const answer = await probe({ host: ticket.service.host, port: ticket.service.port })
75
+ if (answer.kind === 'refused') return { abandoned: true, reason: 'service-address-closed' }
76
+ if (answer.kind === 'health' && (answer.body.runtimeId !== ticket.service.runtimeId || answer.body.pid !== ticket.pid)) return { abandoned: true, reason: 'service-address-answers-as-another-runtime' }
77
+ return { abandoned: false, reason: answer.kind === 'health' ? 'holder-answers-health' : 'live-process-unproven' }
78
+ }
79
+ }
80
+
81
+ function listGenerations(directory) {
82
+ let names
83
+ try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') return { names: [], highest: 0, unknown: null }; throw error }
84
+ names = names.filter((name) => !isPendingPrivateWrite(name))
85
+ const unknown = names.find((name) => !TICKET_NAME.test(name)) ?? null
86
+ const highest = names.reduce((most, name) => Math.max(most, Number(TICKET_NAME.exec(name)?.[1] ?? 0)), 0)
87
+ return { names, highest, unknown }
88
+ }
89
+
90
+ const ticketName = (generation, kind) => `${String(generation).padStart(12, '0')}.${kind}`
91
+
92
+ // Read-only. { available, reason, generation, ticket }
93
+ export async function inspectPrivateGenerationLock({ directory, workspaceId, proveAbandoned = createAbandonmentProof(), nowMs = Date.now() }) {
94
+ const { names, highest, unknown } = listGenerations(directory)
95
+ if (unknown) return { available: false, reason: 'unknown-lock-file', generation: highest, ticket: null }
96
+ if (highest === 0) return { available: true, reason: 'unused', generation: 0, ticket: null }
97
+ let body
98
+ let ticket
99
+ try {
100
+ body = readRegularTextNoFollow(path.join(directory, ticketName(highest, 'json')))
101
+ ticket = validateTicket(JSON.parse(body), workspaceId)
102
+ } catch (error) {
103
+ // Pruned or replaced between the listing and the read: somebody is working here right now.
104
+ return { available: false, reason: error.code === 'ENOENT' ? 'lock-changed-while-reading' : 'unreadable-lock-ticket', generation: highest, ticket: null }
105
+ }
106
+ if (names.includes(ticketName(highest, 'released'))) {
107
+ let released
108
+ try { released = readRegularTextNoFollow(path.join(directory, ticketName(highest, 'released'))) } catch { released = null }
109
+ return released === body ? { available: true, reason: 'released', generation: highest, ticket } : { available: false, reason: 'release-identity-differs', generation: highest, ticket }
110
+ }
111
+ const proof = await proveAbandoned(ticket, { nowMs })
112
+ return { available: proof.abandoned, reason: proof.reason, generation: highest, ticket }
113
+ }
114
+
115
+ // { acquired: true, generation, release() } or { acquired: false, reason, holder }.
116
+ // Never waits and never throws for a lock that is merely held.
117
+ export async function acquirePrivateGenerationLock({ workspaceRoot, directory, workspaceId, purpose, service = null, clock, proveAbandoned, randomBytes = cryptoRandomBytes }) {
118
+ const contained = ensureContainedPrivateDirectory({ workspaceRoot, directory, label: 'private lock' })
119
+ const acquiredAt = isoTime(clock)
120
+ const status = await inspectPrivateGenerationLock({ directory: contained, workspaceId, proveAbandoned, nowMs: Date.parse(acquiredAt) })
121
+ const holder = status.ticket ? { pid: status.ticket.pid, purpose: status.ticket.purpose, runtimeId: status.ticket.service?.runtimeId ?? null } : null
122
+ if (!status.available) return { acquired: false, reason: status.reason, holder }
123
+ if (status.generation >= MAX_GENERATION) return { acquired: false, reason: 'lock-generations-exhausted', holder }
124
+ const generation = status.generation + 1
125
+ const ticket = validateTicket({ schema: LOCK_TICKET_SCHEMA, workspaceId, purpose, pid: process.pid, machine: machineDigest(), nonce: randomBytes(16).toString('hex'), acquiredAt, service }, workspaceId)
126
+ const body = JSON.stringify(ticket)
127
+ const file = path.join(contained, ticketName(generation, 'json'))
128
+ try { publishPrivateFile(file, body) } catch (error) {
129
+ if (error.code === 'EEXIST') return { acquired: false, reason: 'lost-the-race-for-this-generation', holder: null }
130
+ throw error
131
+ }
132
+ // Confirm: a decision made on an old listing finds the newer ticket here and withdraws.
133
+ const after = listGenerations(contained)
134
+ if (after.highest !== generation) {
135
+ try { fs.unlinkSync(file) } catch { /* a lower generation is ignored and pruned by the next holder */ }
136
+ return { acquired: false, reason: 'a-newer-holder-exists', holder: null }
137
+ }
138
+ for (const name of after.names) {
139
+ if (Number(TICKET_NAME.exec(name)[1]) < generation) try { fs.unlinkSync(path.join(contained, name)) } catch { /* pruned by somebody else */ }
140
+ }
141
+ let released = false
142
+ return {
143
+ acquired: true,
144
+ generation,
145
+ release() {
146
+ if (released) return false
147
+ released = true
148
+ let current
149
+ try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') return false; throw error }
150
+ // Taken over after a proof of abandonment (this process was thought gone): nothing here is ours to release.
151
+ if (current !== body) return false
152
+ publishPrivateFile(path.join(contained, ticketName(generation, 'released')), body)
153
+ return true
154
+ },
155
+ }
156
+ }
@@ -0,0 +1,58 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import { executableIdentity } from './service-record.mjs'
4
+
5
+ // Whether a live PID provably runs the executable a service record names,
6
+ // without that process answering anything. Used for one decision only: telling
7
+ // a service of ours that is too busy to answer health (`busy`) from a listener
8
+ // nobody proved (`occupied`). Whatever cannot be established is "not proven".
9
+ //
10
+ // Linux /proc/<pid>/cmdline, the exact argument list
11
+ // macOS /bin/ps -ww -o args= -p <pid>, one line of text
12
+ // Windows not established: a busy service reads as `occupied` there
13
+ //
14
+ // Nothing is looked up by name, port or pattern: the one PID asked about is
15
+ // the PID the owner-only record names.
16
+
17
+ const RUNTIME_ARGUMENT = '--runtime-id='
18
+
19
+ // string[] (exact arguments), string (one joined line) or null.
20
+ export function readProcessCommandLine(pid, { platform = process.platform, readFile = fs.readFileSync, run = execFileSync } = {}) {
21
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null
22
+ try {
23
+ if (platform === 'linux') {
24
+ const words = readFile(`/proc/${pid}/cmdline`, 'utf8').split('\u0000').filter((word) => word !== '')
25
+ return words.length > 0 ? words : null
26
+ }
27
+ if (platform === 'darwin' || platform === 'freebsd' || platform === 'openbsd') {
28
+ const line = run('/bin/ps', ['-ww', '-o', 'args=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, maxBuffer: 1024 * 1024 }).trim()
29
+ return line === '' ? null : line
30
+ }
31
+ } catch { /* not established */ }
32
+ return null
33
+ }
34
+
35
+ // Pure. The command line must name the recorded entry module, and a runtime
36
+ // identifier it carries must be the recorded one.
37
+ export function commandLineNamesRecord(commandLine, record) {
38
+ const entry = record?.executable?.path
39
+ if (typeof entry !== 'string' || entry === '' || typeof record.runtimeId !== 'string') return false
40
+ if (Array.isArray(commandLine)) {
41
+ if (!commandLine.slice(1).includes(entry)) return false
42
+ const named = commandLine.filter((word) => word.startsWith(RUNTIME_ARGUMENT)).map((word) => word.slice(RUNTIME_ARGUMENT.length))
43
+ return named.every((runtimeId) => runtimeId === record.runtimeId)
44
+ }
45
+ if (typeof commandLine !== 'string') return false
46
+ if (!` ${commandLine} `.includes(` ${entry} `)) return false
47
+ const named = [...commandLine.matchAll(/(?:^|\s)--runtime-id=(\S+)/g)].map((match) => match[1])
48
+ return named.every((runtimeId) => runtimeId === record.runtimeId)
49
+ }
50
+
51
+ // The recorded executable is still the bytes that were recorded, and the live
52
+ // process was started on it.
53
+ export function processRunsRecordedExecutable(record, { commandLineOf = readProcessCommandLine, identityOf = executableIdentity } = {}) {
54
+ try {
55
+ if (identityOf(record.executable.path).digest !== record.executable.digest) return false
56
+ } catch { return false }
57
+ return commandLineNamesRecord(commandLineOf(record.pid), record)
58
+ }
@@ -0,0 +1,66 @@
1
+ import http from 'node:http'
2
+ import { isPlainObject } from './documents.mjs'
3
+ import { refuse } from './errors.mjs'
4
+
5
+ // The only client of the maintenance service, and the only place this runtime
6
+ // opens a connection. It connects to a literal loopback address and nothing
7
+ // else: a hostname, a wildcard address or any other value refuses before a
8
+ // socket exists, so no name is ever resolved and nothing leaves the machine.
9
+
10
+ export const LOOPBACK_HOSTS = Object.freeze(['127.0.0.1', '::1'])
11
+ export const HEALTH_SCHEMA = 'atelier-obsidian-service-health/v1'
12
+ // Long enough for a closed port to refuse on every platform: some answer a refused loopback connection only after retrying for about two seconds.
13
+ export const DEFAULT_PROBE_TIMEOUT_MS = 5000
14
+ const MAX_RESPONSE_BYTES = 64 * 1024
15
+
16
+ export const authorityOf = (host, port) => (host === '::1' ? `[::1]:${port}` : `${host}:${port}`)
17
+
18
+ function assertLoopback(host, port) {
19
+ if (!LOOPBACK_HOSTS.includes(host)) refuse('service-address-not-loopback', 'the maintenance service is reached at a literal loopback address only')
20
+ if (!Number.isInteger(port) || port < 1 || port > 65535) refuse('service-address-not-loopback', 'the maintenance service port is not a port')
21
+ }
22
+
23
+ // { kind: 'refused' } nothing listens there
24
+ // { kind: 'timeout' } something accepted the connection and did not answer in time
25
+ // { kind: 'error', code } the connection failed some other way
26
+ // { kind: 'response', statusCode, body } body is parsed JSON, or null
27
+ export function requestLoopback({ host, port, method = 'GET', path = '/health', bearer = null, payload = null, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS }) {
28
+ assertLoopback(host, port)
29
+ return new Promise((resolve) => {
30
+ let settled = false
31
+ const settle = (value) => { if (!settled) { settled = true; resolve(value) } }
32
+ const text = payload === null ? null : JSON.stringify(payload)
33
+ const headers = { Host: authorityOf(host, port), Connection: 'close', ...(bearer === null ? {} : { Authorization: `Bearer ${bearer}` }), ...(text === null ? {} : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) }) }
34
+ // The target is one of two literals, whatever was passed in.
35
+ const request = http.request({ host: host === '::1' ? '::1' : '127.0.0.1', port, family: host === '::1' ? 6 : 4, method, path, headers, agent: false, timeout: timeoutMs }, (response) => {
36
+ const chunks = []
37
+ let size = 0
38
+ response.on('data', (chunk) => {
39
+ size += chunk.length
40
+ if (size > MAX_RESPONSE_BYTES) { request.destroy(); settle({ kind: 'response', statusCode: response.statusCode, body: null }) } else chunks.push(chunk)
41
+ })
42
+ response.on('end', () => {
43
+ let body = null
44
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { body = null }
45
+ settle({ kind: 'response', statusCode: response.statusCode, body: isPlainObject(body) ? body : null })
46
+ })
47
+ response.on('error', () => settle({ kind: 'error', code: 'response-failed' }))
48
+ })
49
+ const timer = setTimeout(() => { request.destroy(); settle({ kind: 'timeout' }) }, timeoutMs)
50
+ request.on('timeout', () => { request.destroy(); settle({ kind: 'timeout' }) })
51
+ request.on('error', (error) => settle(error.code === 'ECONNREFUSED' ? { kind: 'refused' } : { kind: 'error', code: error.code ?? 'request-failed' }))
52
+ request.on('close', () => clearTimeout(timer))
53
+ request.end(text ?? undefined)
54
+ })
55
+ }
56
+
57
+ // { kind: 'health', body } only for a well-formed health document; any other
58
+ // answer on the port is { kind: 'foreign' }: occupied, by something else.
59
+ export async function probeHealth({ host, port, timeoutMs }) {
60
+ const answer = await requestLoopback({ host, port, method: 'GET', path: '/health', ...(timeoutMs === undefined ? {} : { timeoutMs }) })
61
+ if (answer.kind !== 'response') return answer
62
+ const body = answer.body
63
+ const wellFormed = answer.statusCode === 200 && body?.schema === HEALTH_SCHEMA && typeof body.serviceName === 'string' && typeof body.workspaceId === 'string' && typeof body.runtimeId === 'string'
64
+ && Number.isInteger(body.pid) && typeof body.host === 'string' && Number.isInteger(body.port) && typeof body.executableDigest === 'string'
65
+ return wellFormed ? { kind: 'health', body } : { kind: 'foreign', statusCode: answer.statusCode }
66
+ }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { firstString, parseArgs, resolveProjectConfig } from '../../project/config.mjs'
6
+ import { runMaintenanceService } from './service.mjs'
7
+
8
+ // The process of the maintenance service. `start` runs it as a child; a unit
9
+ // installed at the operating-system level runs it with `--startup`.
10
+ //
11
+ // --project=<absolute atelier.project.json> required
12
+ // --data-root=<absolute directory> optional; otherwise the pointer, the overlay or the platform default
13
+ // --runtime-id=<identifier> given by `start`, so it can recognise the child it created
14
+ // --interval-ms=<milliseconds> optional
15
+ // --startup run by an operating-system unit; needs a consent that covers startup
16
+ // --adapter=obsidian-cli the editor adapter; there is no default
17
+ //
18
+ // Without `--adapter` the process refuses before it listens or ticks: reaching
19
+ // a running app is a decision of whoever starts the service, never a default.
20
+
21
+ // Reaching a real app happens here and nowhere else in the service: the
22
+ // production probe and the CLI transport are imported only once `--adapter`
23
+ // selected them. The editor adapter is constructed only for an app that meets
24
+ // the minimum version; below it, or when the version cannot be read, the
25
+ // factory refuses, the engine records that reason and nothing is published.
26
+ const ADAPTERS = Object.freeze({
27
+ 'obsidian-cli': async () => {
28
+ const [{ createObsidianCliAdapter }, { createProductionAppProbe }, { createQualifiedAdapterFactory }] = await Promise.all([
29
+ import('../../projection/obsidian/publication/transport.mjs'), import('./app-production-seams.mjs'), import('./app-capability.mjs'),
30
+ ])
31
+ const adapterFactory = createQualifiedAdapterFactory({ appProbe: createProductionAppProbe(), createAdapter: () => createObsidianCliAdapter() })
32
+ return { adapterFactory, appStatus: () => { const known = adapterFactory.lastQualification(); return known === null ? null : { outcome: known.outcome, reason: known.reason, version: known.version, floor: known.floor } } }
33
+ },
34
+ })
35
+
36
+ export const SERVICE_ENTRY_PATH = fileURLToPath(import.meta.url)
37
+ export const EXIT_REFUSED = 2
38
+
39
+ const log = (entry) => { try { process.stdout.write(`${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
40
+
41
+ // The common body of a service process: run, end cleanly on a signal, exit 0
42
+ // after a clean stop and EXIT_REFUSED when the service refused to run.
43
+ export async function runServiceProcess(options) {
44
+ try {
45
+ const service = await runMaintenanceService({ log, ...options })
46
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { void service.shutdown(`signal-${signal}`) })
47
+ await service.done
48
+ // Everything durable is on disk by now; nothing left over may keep a stopped service's PID alive.
49
+ process.exit(0)
50
+ } catch (error) {
51
+ log({ at: new Date().toISOString(), event: 'refused', code: error?.code ?? 'untyped-error', message: String(error?.message ?? error) })
52
+ process.exitCode = typeof error?.code === 'string' ? EXIT_REFUSED : 1
53
+ }
54
+ }
55
+
56
+ export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
57
+ const args = parseArgs(argv)
58
+ const configPath = firstString(args.project)
59
+ if (!configPath || !path.isAbsolute(configPath)) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
60
+ const dataRoot = firstString(args['data-root']) ?? undefined
61
+ const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
62
+ if (intervalMs !== undefined && (!Number.isInteger(intervalMs) || intervalMs < 1)) throw Object.assign(new Error('--interval-ms must be a positive integer'), { code: 'service-arguments-invalid' })
63
+ return {
64
+ loadProject: () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
65
+ ...(dataRoot === undefined ? {} : { dataRoot }), ...(intervalMs === undefined ? {} : { intervalMs }),
66
+ ...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
67
+ startup: args.startup === true, adapter: firstString(args.adapter),
68
+ }
69
+ }
70
+
71
+ const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(SERVICE_ENTRY_PATH) } catch { return false } })()
72
+
73
+ if (invokedDirectly) {
74
+ let options = null
75
+ try {
76
+ options = serviceOptionsFromArgv(process.argv.slice(2))
77
+ if (!options.adapter || !Object.hasOwn(ADAPTERS, options.adapter)) throw Object.assign(new Error('no editor adapter was selected; pass --adapter explicitly'), { code: 'service-adapter-not-selected' })
78
+ } catch (error) {
79
+ log({ at: new Date().toISOString(), event: 'refused', code: error.code ?? 'untyped-error', message: error.message })
80
+ process.exitCode = EXIT_REFUSED
81
+ options = null
82
+ }
83
+ if (options) {
84
+ const { adapter, ...rest } = options
85
+ const [{ loadContributions }, { createObsidianRegistry }] = await Promise.all([import('./contributions.mjs'), import('./extension-points.mjs')])
86
+ const registry = createObsidianRegistry({ contributions: await loadContributions() })
87
+ await runServiceProcess({ ...rest, entryPath: SERVICE_ENTRY_PATH, ...(await ADAPTERS[adapter]()), engineOptions: { extensions: registry.extensions } })
88
+ }
89
+ }
@@ -0,0 +1,169 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../project/private-state.mjs'
5
+ import { ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
6
+ import { canonicalJson, closedObject } from './documents.mjs'
7
+ import { refuse } from './errors.mjs'
8
+ import { LOOPBACK_HOSTS } from './service-client.mjs'
9
+
10
+ // Private documents of the maintenance service of one workspace, under
11
+ //
12
+ // <data>/obsidian/<workspace-id>/state/service/
13
+ // runtime.json the adapter record of the running service (service-state v1)
14
+ // settings.json what this machine chose: loopback host, port, startup consent
15
+ // last-error.json the last tick that failed for a reason nobody typed
16
+ // service.log the operational log of the service process
17
+ // start-lock/ serializes `start` for this workspace
18
+ //
19
+ // Owner-only, replaced atomically, outside every repository and every vault.
20
+ // Every document is validated on every read. One that does not validate, or
21
+ // that names another workspace, service or state location, refuses: it is
22
+ // never repaired, never adopted and never overwritten without a person.
23
+
24
+ export const SERVICE_SETTINGS_SCHEMA = 'atelier-obsidian-service-settings/v1'
25
+ export const SERVICE_ERROR_SCHEMA = 'atelier-obsidian-service-last-error/v1'
26
+ export const CONSENT_COVERAGES = Object.freeze(['service', 'service-and-startup'])
27
+
28
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
29
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
30
+ const BEARER = /^[A-Za-z0-9_-]{43}$/
31
+
32
+ export function serviceNameFor(workspaceId) {
33
+ const name = `atelier-obsidian-${workspaceId}`
34
+ if (!IDENTIFIER.test(name)) refuse('invalid-workspace-identity', 'the workspace identity cannot name a service')
35
+ return name
36
+ }
37
+
38
+ export function servicePaths(workspaceRoot) {
39
+ const directory = path.join(workspaceRoot, 'state', 'service')
40
+ return {
41
+ stateLocation: path.join(workspaceRoot, 'state'), directory, record: path.join(directory, 'runtime.json'), settings: path.join(directory, 'settings.json'),
42
+ lastError: path.join(directory, 'last-error.json'), log: path.join(directory, 'service.log'), startLock: path.join(directory, 'start-lock'),
43
+ }
44
+ }
45
+
46
+ const serviceDirectory = (workspaceRoot) => ensureContainedPrivateDirectory({ workspaceRoot, directory: servicePaths(workspaceRoot).directory, label: 'Obsidian service state' })
47
+
48
+ function readJson(file, code, label) {
49
+ let text
50
+ try { text = readRegularTextNoFollow(file) } catch (error) {
51
+ if (error.code === 'ENOENT') return null
52
+ refuse(code, `${label} cannot be read`, { cause: error.code ?? String(error.message) })
53
+ }
54
+ try { return JSON.parse(text) } catch { return refuse(code, `${label} is not JSON`) }
55
+ }
56
+
57
+ // The identity of what runs: the service entry module and the digest of its bytes.
58
+ export function executableIdentity(entryPath) {
59
+ const resolved = fs.realpathSync(entryPath)
60
+ return { path: resolved, digest: `sha256:${createHash('sha256').update(fs.readFileSync(resolved)).digest('hex')}` }
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // The adapter record
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export function validateServiceRecord(document, { workspaceRoot, workspaceId }) {
68
+ const code = 'invalid-service-record'
69
+ try { assertObsidianContract('service-state', document) } catch (error) {
70
+ if (error instanceof ObsidianContractRefusal) refuse(code, 'the service record does not satisfy its contract', { errors: error.detail?.errors ?? [] })
71
+ throw error
72
+ }
73
+ if (document.workspaceId !== workspaceId || document.serviceName !== serviceNameFor(workspaceId)) refuse(code, 'the service record belongs to another workspace or service')
74
+ if (document.stateLocation !== servicePaths(workspaceRoot).stateLocation) refuse(code, 'the service record names another state location')
75
+ if (!path.isAbsolute(document.executable.path)) refuse(code, 'the service record names an executable that is not an absolute path')
76
+ // The bearer of the running service lives here and nowhere else.
77
+ closedObject(document.ext, { required: ['bearer'] }, code, 'the private part of the service record')
78
+ if (typeof document.ext.bearer !== 'string' || !BEARER.test(document.ext.bearer)) refuse(code, 'the service record carries no usable bearer')
79
+ return document
80
+ }
81
+
82
+ export function readServiceRecord({ workspaceRoot, workspaceId }) {
83
+ const document = readJson(servicePaths(workspaceRoot).record, 'invalid-service-record', 'the service record')
84
+ return document === null ? null : validateServiceRecord(document, { workspaceRoot, workspaceId })
85
+ }
86
+
87
+ export function writeServiceRecord({ workspaceRoot, workspaceId, record }) {
88
+ validateServiceRecord(record, { workspaceRoot, workspaceId })
89
+ atomicReplacePrivateText(path.join(serviceDirectory(workspaceRoot), 'runtime.json'), canonicalJson(record))
90
+ return record
91
+ }
92
+
93
+ // Removes the record only while it still names this runtime and PID. Nothing else is ever removed here.
94
+ export function removeServiceRecord({ workspaceRoot, workspaceId, runtimeId, pid }) {
95
+ let current
96
+ try { current = readServiceRecord({ workspaceRoot, workspaceId }) } catch { return false }
97
+ if (current === null || current.runtimeId !== runtimeId || current.pid !== pid) return false
98
+ try { fs.unlinkSync(servicePaths(workspaceRoot).record); return true } catch (error) { if (error.code === 'ENOENT') return false; throw error }
99
+ }
100
+
101
+ // What may be shown: everything but the bearer.
102
+ export function publicRecord(record) {
103
+ if (record === null) return null
104
+ const { ext: _private, ...shown } = record
105
+ return shown
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Machine-specific service settings
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function validateServiceSettings(document, workspaceId) {
113
+ const code = 'invalid-service-settings'
114
+ closedObject(document, { required: ['schema', 'workspaceId', 'host', 'port', 'consent', 'updatedAt'] }, code, 'service settings')
115
+ if (document.schema !== SERVICE_SETTINGS_SCHEMA || document.workspaceId !== workspaceId) refuse(code, 'service settings name an unknown schema or another workspace')
116
+ if (!LOOPBACK_HOSTS.includes(document.host)) refuse(code, 'the service host must be the literal 127.0.0.1 or ::1')
117
+ if (!Number.isInteger(document.port) || document.port < 1024 || document.port > 65535) refuse(code, 'the service port must be between 1024 and 65535')
118
+ closedObject(document.consent, { required: ['grantedAt', 'actor', 'coverage'] }, code, 'the startup consent')
119
+ if (!TIMESTAMP.test(document.consent.grantedAt) || typeof document.consent.actor !== 'string' || !IDENTIFIER.test(document.consent.actor) || !CONSENT_COVERAGES.includes(document.consent.coverage)) refuse(code, 'the startup consent is malformed')
120
+ if (typeof document.updatedAt !== 'string' || !TIMESTAMP.test(document.updatedAt)) refuse(code, 'updatedAt must be a UTC timestamp')
121
+ return document
122
+ }
123
+
124
+ export function readServiceSettings({ workspaceRoot, workspaceId }) {
125
+ const document = readJson(servicePaths(workspaceRoot).settings, 'invalid-service-settings', 'service settings')
126
+ return document === null ? null : validateServiceSettings(document, workspaceId)
127
+ }
128
+
129
+ export function writeServiceSettings({ workspaceRoot, workspaceId, settings }) {
130
+ validateServiceSettings(settings, workspaceId)
131
+ atomicReplacePrivateText(path.join(serviceDirectory(workspaceRoot), 'settings.json'), canonicalJson(settings))
132
+ return settings
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // The last untyped tick error, and the operational log
137
+ // ---------------------------------------------------------------------------
138
+
139
+ function validateLastError(document, workspaceId) {
140
+ const code = 'invalid-service-last-error'
141
+ closedObject(document, { required: ['schema', 'workspaceId', 'runtimeId', 'code', 'name', 'at', 'consecutiveFailures', 'totalFailures', 'resolvedAt'] }, code, 'the last service error')
142
+ const ok = document.schema === SERVICE_ERROR_SCHEMA && document.workspaceId === workspaceId && typeof document.runtimeId === 'string' && IDENTIFIER.test(document.runtimeId)
143
+ && typeof document.code === 'string' && document.code.length <= 64 && typeof document.name === 'string' && document.name.length <= 64 && TIMESTAMP.test(document.at)
144
+ && Number.isInteger(document.consecutiveFailures) && document.consecutiveFailures >= 0 && Number.isInteger(document.totalFailures) && document.totalFailures >= 1
145
+ && (document.resolvedAt === null || TIMESTAMP.test(document.resolvedAt))
146
+ if (!ok) refuse(code, 'the last service error is malformed')
147
+ return document
148
+ }
149
+
150
+ export function readLastServiceError({ workspaceRoot, workspaceId }) {
151
+ const document = readJson(servicePaths(workspaceRoot).lastError, 'invalid-service-last-error', 'the last service error')
152
+ return document === null ? null : validateLastError(document, workspaceId)
153
+ }
154
+
155
+ // An error code and class only: a message can carry a path or a title, and goes to the private log instead.
156
+ export function writeLastServiceError({ workspaceRoot, workspaceId, document }) {
157
+ validateLastError(document, workspaceId)
158
+ atomicReplacePrivateText(path.join(serviceDirectory(workspaceRoot), 'last-error.json'), canonicalJson(document))
159
+ return document
160
+ }
161
+
162
+ // The log is bounded: past the ceiling it becomes the one previous log, and a new one begins.
163
+ export const SERVICE_LOG_MAX_BYTES = 1024 * 1024
164
+
165
+ export function openServiceLog(workspaceRoot) {
166
+ const file = path.join(serviceDirectory(workspaceRoot), 'service.log')
167
+ try { if (fs.lstatSync(file).size > SERVICE_LOG_MAX_BYTES) fs.renameSync(file, `${file}.1`) } catch (error) { if (error.code !== 'ENOENT') throw error }
168
+ return { file, descriptor: openRegularFileNoFollow(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600) }
169
+ }