@mnstry/atelier 0.2.0-alpha.1 → 0.2.0-alpha.11

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 (414) hide show
  1. package/CHANGELOG.md +413 -0
  2. package/README.md +402 -205
  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/contracts/public-api-baseline.json +57 -0
  31. package/docs/assurance-controls.md +49 -0
  32. package/docs/atelier-runtime.md +28 -2
  33. package/docs/atelier-sync.md +172 -0
  34. package/docs/blocks/audience-visibility.md +9 -0
  35. package/docs/blocks/claims.md +61 -0
  36. package/docs/blocks/conformance-admission.md +15 -0
  37. package/docs/blocks/will-not-do.md +18 -0
  38. package/docs/coauthor-session.md +57 -0
  39. package/docs/connected-composition.md +75 -0
  40. package/docs/continuity.md +3 -4
  41. package/docs/decisions.md +167 -0
  42. package/docs/design.md +167 -0
  43. package/docs/exact-upgrades.md +159 -0
  44. package/docs/extension-pack-lifecycle.md +33 -0
  45. package/docs/guided-upgrades.md +99 -0
  46. package/docs/install.md +99 -16
  47. package/docs/intake-and-guides.md +65 -0
  48. package/docs/integration-and-review-plan.md +409 -0
  49. package/docs/integration-contract-decisions.md +22 -0
  50. package/docs/knowledge-graph.md +8 -4
  51. package/docs/local-review.md +84 -0
  52. package/docs/local-services.md +328 -0
  53. package/docs/local-state.md +107 -0
  54. package/docs/obsidian-contract.md +698 -0
  55. package/docs/obsidian.md +484 -0
  56. package/docs/portable-presentation.md +328 -0
  57. package/docs/presentation-consumer-boundaries.md +75 -0
  58. package/docs/presentation-review-disposition.md +47 -0
  59. package/docs/project-command-inventory.md +38 -0
  60. package/docs/project-options.md +53 -0
  61. package/docs/release-engineering.md +174 -12
  62. package/docs/repo-boundary-guard.md +12 -2
  63. package/docs/review-portability.md +55 -0
  64. package/docs/skill-steward.md +156 -0
  65. package/docs/upgrade.md +155 -5
  66. package/docs/vault-service.md +292 -0
  67. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  68. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  69. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  70. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  71. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  72. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  73. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  74. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  75. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  76. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  77. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  78. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  79. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  80. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  81. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  82. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  83. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  84. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  85. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  86. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  87. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  88. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  89. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  90. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  91. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  92. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  93. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  94. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  95. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  96. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  97. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  98. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  99. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  100. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  101. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  102. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  103. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  104. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  105. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  106. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  107. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  108. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  109. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  110. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  111. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  112. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  113. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  114. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  115. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  116. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  117. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  118. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  119. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  120. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  121. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  122. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  123. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  127. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  128. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  129. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  130. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  131. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  132. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  133. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  134. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  135. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  136. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  137. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  138. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  141. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  142. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  143. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  144. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  145. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  146. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  147. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  148. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  149. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  150. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  151. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  152. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  153. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  154. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  159. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  160. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  161. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  162. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  163. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  164. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  165. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  166. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  167. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  168. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  169. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  170. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  171. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  172. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  173. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  174. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  175. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  176. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  177. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  178. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  179. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  181. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  182. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  183. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  186. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  187. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  188. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  189. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  190. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  191. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  192. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  193. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  194. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  195. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  196. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  197. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  198. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  199. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  200. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  201. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  202. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  203. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  204. package/fixtures/obsidian/edits/workspace.json +65 -0
  205. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  206. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  207. package/fixtures/obsidian/materialization/workspace.json +54 -0
  208. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  209. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  210. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  211. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  212. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  213. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  214. package/fixtures/ui/presentation/pane.v1.json +7 -0
  215. package/fixtures/ui/presentation/reference.v1.json +293 -0
  216. package/package.json +86 -10
  217. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  218. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  219. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  220. package/skills/claude/atelier-open/SKILL.md +11 -5
  221. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  222. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  223. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  224. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  225. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  226. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  227. package/skills/codex/atelier-open/SKILL.md +11 -5
  228. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  229. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  230. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  231. package/src/access/preview-enforcer.mjs +84 -0
  232. package/src/boundary/content-rules.mjs +283 -20
  233. package/src/boundary/policy.mjs +213 -91
  234. package/src/cli/execute-command.mjs +36 -0
  235. package/src/cli/project-options.mjs +58 -0
  236. package/src/cli/run.mjs +78 -10
  237. package/src/coauthor/session.mjs +161 -0
  238. package/src/coauthor/store.mjs +173 -0
  239. package/src/collaboration/event-ledger.mjs +348 -0
  240. package/src/collaboration/index.mjs +17 -0
  241. package/src/collaboration/inspection-bundle.mjs +223 -0
  242. package/src/collaboration/proposals.mjs +265 -65
  243. package/src/collaboration/review-contracts.mjs +25 -0
  244. package/src/collaboration/review-store.mjs +305 -0
  245. package/src/commands/attestation.mjs +20 -6
  246. package/src/commands/coauthor.mjs +26 -0
  247. package/src/commands/config.mjs +6 -1
  248. package/src/commands/disclosure.mjs +133 -0
  249. package/src/commands/distribution.mjs +2 -1
  250. package/src/commands/extension-pack.mjs +17 -9
  251. package/src/commands/init.mjs +46 -6
  252. package/src/commands/obsidian.mjs +371 -0
  253. package/src/commands/review.mjs +147 -0
  254. package/src/commands/server.mjs +1 -4
  255. package/src/commands/setup.mjs +51 -14
  256. package/src/commands/skills.mjs +199 -0
  257. package/src/commands/sync.mjs +107 -0
  258. package/src/composition/registry.mjs +27 -0
  259. package/src/composition/wire.mjs +122 -0
  260. package/src/contracts/corpus.mjs +35 -0
  261. package/src/decisions/contracts.d.mts +96 -0
  262. package/src/decisions/contracts.mjs +321 -0
  263. package/src/disclosure/content-scan.mjs +206 -0
  264. package/src/egress/check.mjs +7 -38
  265. package/src/egress/forbidden-egress.mjs +32 -18
  266. package/src/export/atelier-export-contract.mjs +16 -1
  267. package/src/extension-packs/lifecycle.mjs +154 -0
  268. package/src/graph/graph.mjs +176 -316
  269. package/src/graph/knowledge-graph.mjs +643 -43
  270. package/src/guides/contracts.mjs +54 -0
  271. package/src/harness/context-client.mjs +9 -1
  272. package/src/index.mjs +41 -0
  273. package/src/intake/store.mjs +119 -0
  274. package/src/preview/channel.mjs +89 -0
  275. package/src/project/config.mjs +112 -52
  276. package/src/project/durable-state.mjs +161 -0
  277. package/src/project/file-class.mjs +180 -0
  278. package/src/project/package-root.mjs +10 -0
  279. package/src/project/path-match.mjs +38 -15
  280. package/src/project/private-state.mjs +120 -0
  281. package/src/projection/obsidian/contracts.mjs +571 -0
  282. package/src/projection/obsidian/edits/align.mjs +169 -0
  283. package/src/projection/obsidian/edits/apply.mjs +889 -0
  284. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  285. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  286. package/src/projection/obsidian/edits/index.mjs +19 -0
  287. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  288. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  289. package/src/projection/obsidian/edits/observe.mjs +123 -0
  290. package/src/projection/obsidian/edits/policy.mjs +123 -0
  291. package/src/projection/obsidian/edits/regions.mjs +648 -0
  292. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  293. package/src/projection/obsidian/materialize/index.mjs +7 -0
  294. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  295. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  296. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  297. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  298. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  299. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  300. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  301. package/src/projection/obsidian/proposals/index.mjs +23 -0
  302. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  303. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  304. package/src/projection/obsidian/proposals/router.mjs +168 -0
  305. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  306. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  307. package/src/projection/obsidian/publication/index.mjs +5 -0
  308. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  309. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  310. package/src/projection/obsidian/publication/transport.mjs +212 -0
  311. package/src/projection/obsidian/recovery/index.mjs +6 -0
  312. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  313. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  314. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  315. package/src/projection/obsidian/recovery/store.mjs +234 -0
  316. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  317. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  318. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  319. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  320. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  321. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  322. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  323. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  324. package/src/projection/project.mjs +28 -19
  325. package/src/readiness-protocols/evidence.mjs +282 -0
  326. package/src/readiness-protocols/runtime.mjs +4 -3
  327. package/src/readiness-protocols/source-read.mjs +39 -0
  328. package/src/runtime/git-adapter.mjs +189 -0
  329. package/src/runtime/local-state.mjs +439 -0
  330. package/src/runtime/obsidian/app-capability.mjs +138 -0
  331. package/src/runtime/obsidian/app-production-seams.mjs +107 -0
  332. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  333. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  334. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  335. package/src/runtime/obsidian/contributions.mjs +27 -0
  336. package/src/runtime/obsidian/documents.mjs +37 -0
  337. package/src/runtime/obsidian/enablement.mjs +53 -0
  338. package/src/runtime/obsidian/engine.mjs +532 -0
  339. package/src/runtime/obsidian/errors.mjs +14 -0
  340. package/src/runtime/obsidian/extension-points.mjs +103 -0
  341. package/src/runtime/obsidian/index.mjs +34 -0
  342. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  343. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  344. package/src/runtime/obsidian/observation.mjs +158 -0
  345. package/src/runtime/obsidian/opening.mjs +280 -0
  346. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  347. package/src/runtime/obsidian/pipeline.mjs +122 -0
  348. package/src/runtime/obsidian/private-lock.mjs +156 -0
  349. package/src/runtime/obsidian/process-identity.mjs +58 -0
  350. package/src/runtime/obsidian/service-client.mjs +66 -0
  351. package/src/runtime/obsidian/service-entry-path.mjs +9 -0
  352. package/src/runtime/obsidian/service-main.mjs +93 -0
  353. package/src/runtime/obsidian/service-record.mjs +169 -0
  354. package/src/runtime/obsidian/service-server.mjs +121 -0
  355. package/src/runtime/obsidian/service.mjs +216 -0
  356. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  357. package/src/runtime/obsidian/state-store.mjs +180 -0
  358. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  359. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  360. package/src/runtime/obsidian/watchers.mjs +36 -0
  361. package/src/runtime/repository-observation.mjs +491 -0
  362. package/src/runtime/supervisor.mjs +803 -0
  363. package/src/server/local-sidecar.mjs +125 -59
  364. package/src/server/security.mjs +89 -4
  365. package/src/server/server.mjs +4 -2
  366. package/src/skills/steward.mjs +780 -0
  367. package/src/support/feedback-report.mjs +4 -3
  368. package/src/ui/presentation/browser.mjs +278 -0
  369. package/src/ui/presentation/contract.mjs +116 -0
  370. package/src/ui/presentation/index.mjs +7 -0
  371. package/src/ui/presentation/native.mjs +153 -0
  372. package/src/ui/presentation/proof.mjs +57 -0
  373. package/src/ui/presentation/schema-check.mjs +30 -0
  374. package/src/ui/presentation/schema.generated.mjs +1371 -0
  375. package/src/ui/presentation/state.mjs +43 -0
  376. package/src/ui/presentation/styles.mjs +67 -0
  377. package/src/ui/presentation/tokens.mjs +74 -0
  378. package/src/ui/presentation/web.mjs +85 -0
  379. package/src/ui/review-page.mjs +32 -0
  380. package/src/upgrade/explanation.mjs +33 -0
  381. package/src/upgrade/provenance.mjs +338 -0
  382. package/src/upgrade/transaction-files.mjs +103 -0
  383. package/src/upgrade/transaction.mjs +475 -0
  384. package/src/upgrade/upgrade.mjs +65 -29
  385. package/src/vault/hosts.mjs +21 -0
  386. package/src/vault/identity.mjs +19 -0
  387. package/src/vault/index.mjs +7 -0
  388. package/src/vault/interface.mjs +11 -0
  389. package/src/vault/metadata.mjs +31 -0
  390. package/src/vault/privacy.mjs +148 -0
  391. package/src/vault/probe.mjs +89 -0
  392. package/src/vault/service.mjs +131 -0
  393. package/src/vault/source.mjs +45 -0
  394. package/src/vault/storage.mjs +23 -0
  395. package/templates/distribution-workspace/.claude/launch.json +6 -2
  396. package/templates/distribution-workspace/gitignore +1 -0
  397. package/templates/distribution-workspace/project/README.md +2 -2
  398. package/templates/external-project-workspace/README.md +45 -0
  399. package/templates/external-project-workspace/answers.example.json +4 -0
  400. package/templates/external-project-workspace/atelier.project.json +40 -0
  401. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  402. package/templates/external-project-workspace/gitignore +8 -0
  403. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  404. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  405. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  406. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  407. package/templates/external-project-workspace/source/README.md +11 -0
  408. package/templates/launch.json +6 -2
  409. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  410. package/templates/private-domain-workspace/domain/README.md +2 -2
  411. package/templates/private-domain-workspace/gitignore +1 -0
  412. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  413. package/templates/shared-project-workspace/gitignore +1 -0
  414. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,122 @@
1
+ import { createHash } from 'node:crypto'
2
+ import path from 'node:path'
3
+ import { buildCanonicalGraph, createGraphFileCache } from '../../graph/graph.mjs'
4
+ import { EMITTER_VERSION, createPreparationCache, prepareView, withEligibility } from '../../projection/obsidian/materialize/index.mjs'
5
+ import { publishView } from '../../projection/obsidian/publication/publisher.mjs'
6
+ import { recheckDisplacedFiles } from '../../projection/obsidian/recovery/late-writer.mjs'
7
+ import { createRecoveryStore, readFileBytes } from '../../projection/obsidian/recovery/store.mjs'
8
+ import { compareText } from './documents.mjs'
9
+ import { refuse } from './errors.mjs'
10
+ import { readFileFacts, sha256Digest, sourceKey } from './observation.mjs'
11
+
12
+ // The production seams between the engine and the projection modules:
13
+ //
14
+ // canonical graph -> source snapshot -> prepareView -> publishView
15
+ //
16
+ // The engine receives these as one object and tests replace single members.
17
+ // Nothing here talks to a network, and nothing here asks git anything; the
18
+ // canonical graph builder reads local ignore rules through the local git
19
+ // executable and that is all.
20
+
21
+ // Fails closed twice. A document is eligible only when the census classified
22
+ // it: an unclassified one, or one that carries no classification at all, is
23
+ // withheld. An embedded asset has no audience of its own, so it is eligible
24
+ // only when at least one note that embeds it is itself eligible; a file that
25
+ // only withheld notes embed never reaches a view, a snapshot or a state
26
+ // document. `isAssetEligible(asset, { embeds, isEligibleSource })` may be
27
+ // supplied to replace the asset rule; only an exact `true` admits an asset, and
28
+ // `revision()` must change whenever either rule does.
29
+ export const DEFAULT_ELIGIBILITY = Object.freeze({
30
+ revision: () => 'classified-documents/v2+assets-embedded-by-eligible-documents/v1',
31
+ isEligible: (node) => node.classification === 'classified',
32
+ })
33
+
34
+ export function assetEligibilityFor({ graph, eligibility }) {
35
+ const eligibleSources = new Set(graph.nodes.filter((node) => eligibility.isEligible(node) === true).map((node) => node.id))
36
+ const embeds = Array.isArray(graph.embeds) ? graph.embeds : []
37
+ if (typeof eligibility.isAssetEligible === 'function') {
38
+ return (asset) => eligibility.isAssetEligible(asset, { embeds: embeds.filter((embed) => embed?.asset?.id === asset.id), isEligibleSource: (id) => eligibleSources.has(id) }) === true
39
+ }
40
+ const embedded = new Set(embeds.filter((embed) => eligibleSources.has(embed?.source)).map((embed) => embed?.asset?.id))
41
+ return (asset) => embedded.has(asset.id)
42
+ }
43
+
44
+ const posix = (value) => value.split(path.sep).join('/')
45
+
46
+ // The corpus profile of a loaded project. Repository roots are recorded
47
+ // relative to the workspace root, since a profile is a portable shape; a
48
+ // repository kept elsewhere on this machine is recorded by name only.
49
+ export function profileFor({ project, workspaceId, audienceAllow }) {
50
+ const repositories = (project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => {
51
+ const relative = posix(path.relative(project.workspaceRoot, repo.path))
52
+ const inside = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
53
+ return { repoId: repo.name, root: relative === '' ? '.' : inside ? relative : `elsewhere/${repo.name}`, enrollment: 'enrolled' }
54
+ })
55
+ return { schema: 'atelier-obsidian-corpus-profile/v1', workspaceId, repositories, audience: { allow: [...audienceAllow] } }
56
+ }
57
+
58
+ // `cache` (createGraphFileCache) is the engine's per-file census cache; without it every source is parsed again.
59
+ // `index` is the engine's observation index. With both, a source whose observed digest is the cached one is not
60
+ // opened: observation already decided by digest that it did not change, under the stat-hint bound observation
61
+ // documents, and a full reconciliation hashes it again. Without an index every source is read and hashed here.
62
+ export function buildGraph({ project, eligibility, cache = null, index = null }) {
63
+ const observedDigest = cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null
64
+ const canonical = buildCanonicalGraph(project, { fileCache: cache, observedDigest })
65
+ if (!canonical.ok) refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
66
+ return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
67
+ }
68
+
69
+ // A source snapshot pinned to the digests observation decided on. prepareView
70
+ // reads every source it emits again and refuses with `mixed-read` when the
71
+ // bytes it gets are not the pinned ones, so an emitted note never carries a
72
+ // stale digest. A note reused from the preparation cache is not read again:
73
+ // its pinned digest is unchanged and the cached bytes are the ones that digest
74
+ // describes, so bytes that drift under an unchanged pin are not consulted
75
+ // until observation hashes the file again (the same bound as the graph stage).
76
+ export function captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt }) {
77
+ const roots = new Map((project.repos ?? []).filter((repo) => !repo.external).map((repo) => [repo.name, repo.path]))
78
+ const absolute = (repoId, relative) => {
79
+ const root = roots.get(repoId)
80
+ if (!root) refuse('source-not-in-snapshot', 'a node names a repository the project does not enrol')
81
+ return path.join(root, ...relative.split('/'))
82
+ }
83
+ const byRepo = new Map([...roots.keys()].map((repoId) => [repoId, new Map()]))
84
+ // Every node, and every asset a view may copy. A withheld asset is never pinned: it is in no snapshot document.
85
+ const pinned = [...graph.nodes, ...(Array.isArray(graph.assets) ? graph.assets.filter((asset) => asset.eligible === true) : [])]
86
+ for (const item of pinned) {
87
+ if (!byRepo.has(item.repo)) refuse('source-not-in-snapshot', 'the graph names a repository the project does not enrol')
88
+ const observed = index.get(sourceKey(item.repo, item.path))
89
+ const facts = observed?.digest ? observed : readFileFacts(absolute(item.repo, item.path))
90
+ if (!facts) refuse('mixed-read', 'a file the graph read is no longer there')
91
+ byRepo.get(item.repo).set(item.path, { path: item.path, rawDigest: facts.digest, byteLength: facts.byteLength })
92
+ }
93
+ const repositories = [...byRepo].sort(([left], [right]) => compareText(left, right))
94
+ .map(([repoId, files]) => ({ repoId, head: null, dirty: true, files: [...files.values()].sort((left, right) => compareText(left.path, right.path)) }))
95
+ // Embeds and assets are part of what was read: an embed that changes alone yields another snapshot.
96
+ const graphDigest = sha256Digest(Buffer.from(JSON.stringify([graph.nodes, graph.edges, graph.embeds ?? [], graph.assets ?? []])))
97
+ const identity = createHash('sha256').update(JSON.stringify([workspaceId, graphDigest, configDigest, repositories])).digest('hex').slice(0, 32)
98
+ return {
99
+ document: {
100
+ schema: 'atelier-obsidian-source-snapshot/v1',
101
+ contractVersion: '1.0.0',
102
+ snapshotId: `snap-${identity}`,
103
+ workspaceId,
104
+ capturedAt,
105
+ readConsistency: 'single-read',
106
+ graphPin: { graphDigest, nodeCount: graph.nodes.length, edgeCount: graph.edges.length },
107
+ versions: { source: '1', config: configDigest.slice('sha256:'.length, 'sha256:'.length + 16), emitter: EMITTER_VERSION },
108
+ repositories,
109
+ },
110
+ graph,
111
+ readSource: (repoId, relative) => readFileBytes(absolute(repoId, relative)),
112
+ }
113
+ }
114
+
115
+ // The engine keeps one graph file cache and one preparation cache per scope
116
+ // for as long as it runs, and hands them to buildGraph and prepareView on
117
+ // every tick, so a tick after a one-note change parses that source and emits
118
+ // that note and reuses the rest. Both caches are derived, in-memory state: a
119
+ // test may replace either seam with `() => null` to build or prepare in full.
120
+ export function createProductionSeams() {
121
+ return { buildGraph, captureSnapshot, profileFor, prepareView, publishView, createRecoveryStore, recheckDisplacedFiles, createGraphCache: createGraphFileCache, createPreparationCache }
122
+ }
@@ -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,9 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ // The absolute path of the maintenance service entry, kept in its own module
4
+ // so that nothing which needs the path has to import the entry itself. The
5
+ // entry awaits at top level (it loads the command contributions before it
6
+ // serves), and a module that both reaches it through a static import and is
7
+ // itself reached by that load would deadlock the process: the contribution
8
+ // graph waits for the entry, the entry waits for the contribution graph.
9
+ export const SERVICE_ENTRY_PATH = fileURLToPath(new URL('./service-main.mjs', import.meta.url))
@@ -0,0 +1,93 @@
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
+ // An adapter qualified while no app ran carries that qualification and does
27
+ // not coordinate with an app that starts before the next qualification.
28
+ const ADAPTERS = Object.freeze({
29
+ 'obsidian-cli': async () => {
30
+ const [{ createObsidianCliAdapter }, { createProductionAppProbe }, { createQualifiedAdapterFactory }] = await Promise.all([
31
+ import('../../projection/obsidian/publication/transport.mjs'), import('./app-production-seams.mjs'), import('./app-capability.mjs'),
32
+ ])
33
+ const adapterFactory = createQualifiedAdapterFactory({ appProbe: createProductionAppProbe(), createAdapter: ({ qualification }) => createObsidianCliAdapter({ qualification }) })
34
+ return { adapterFactory, appStatus: () => { const known = adapterFactory.lastQualification(); return known === null ? null : { outcome: known.outcome, reason: known.reason, version: known.version, floor: known.floor } } }
35
+ },
36
+ })
37
+
38
+ import { SERVICE_ENTRY_PATH } from './service-entry-path.mjs'
39
+
40
+ export { SERVICE_ENTRY_PATH }
41
+ export const EXIT_REFUSED = 2
42
+
43
+ const log = (entry) => { try { process.stdout.write(`${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
44
+
45
+ // The common body of a service process: run, end cleanly on a signal, exit 0
46
+ // after a clean stop and EXIT_REFUSED when the service refused to run.
47
+ export async function runServiceProcess(options) {
48
+ try {
49
+ const service = await runMaintenanceService({ log, ...options })
50
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { void service.shutdown(`signal-${signal}`) })
51
+ await service.done
52
+ // Everything durable is on disk by now; nothing left over may keep a stopped service's PID alive.
53
+ process.exit(0)
54
+ } catch (error) {
55
+ log({ at: new Date().toISOString(), event: 'refused', code: error?.code ?? 'untyped-error', message: String(error?.message ?? error) })
56
+ process.exitCode = typeof error?.code === 'string' ? EXIT_REFUSED : 1
57
+ }
58
+ }
59
+
60
+ export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
61
+ const args = parseArgs(argv)
62
+ const configPath = firstString(args.project)
63
+ if (!configPath || !path.isAbsolute(configPath)) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
64
+ const dataRoot = firstString(args['data-root']) ?? undefined
65
+ const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
66
+ if (intervalMs !== undefined && (!Number.isInteger(intervalMs) || intervalMs < 1)) throw Object.assign(new Error('--interval-ms must be a positive integer'), { code: 'service-arguments-invalid' })
67
+ return {
68
+ loadProject: () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
69
+ ...(dataRoot === undefined ? {} : { dataRoot }), ...(intervalMs === undefined ? {} : { intervalMs }),
70
+ ...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
71
+ startup: args.startup === true, adapter: firstString(args.adapter),
72
+ }
73
+ }
74
+
75
+ const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)) } catch { return false } })()
76
+
77
+ if (invokedDirectly) {
78
+ let options = null
79
+ try {
80
+ options = serviceOptionsFromArgv(process.argv.slice(2))
81
+ 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' })
82
+ } catch (error) {
83
+ log({ at: new Date().toISOString(), event: 'refused', code: error.code ?? 'untyped-error', message: error.message })
84
+ process.exitCode = EXIT_REFUSED
85
+ options = null
86
+ }
87
+ if (options) {
88
+ const { adapter, ...rest } = options
89
+ const [{ loadContributions }, { createObsidianRegistry }] = await Promise.all([import('./contributions.mjs'), import('./extension-points.mjs')])
90
+ const registry = createObsidianRegistry({ contributions: await loadContributions() })
91
+ await runServiceProcess({ ...rest, entryPath: SERVICE_ENTRY_PATH, ...(await ADAPTERS[adapter]()), engineOptions: { extensions: registry.extensions } })
92
+ }
93
+ }