@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,14 @@
1
+ // Every refusal of the maintenance runtime is this one typed error. `code` is
2
+ // the stable, machine-readable reason; `detail` never carries note bytes.
3
+ export class ObsidianMaintenanceRefusal extends Error {
4
+ constructor(code, message, detail = {}) {
5
+ super(`${code}: ${message}`)
6
+ this.name = 'ObsidianMaintenanceRefusal'
7
+ this.code = code
8
+ this.detail = detail
9
+ }
10
+ }
11
+
12
+ export function refuse(code, message, detail) {
13
+ throw new ObsidianMaintenanceRefusal(code, message, detail)
14
+ }
@@ -0,0 +1,103 @@
1
+ import { refuse } from './errors.mjs'
2
+
3
+ // The neutral registration point for the operations that later work supplies:
4
+ // the source apply operation and the proposal adapter. A registry is a plain
5
+ // value created by whoever composes the engine and handed to it. There is no
6
+ // module-level registry, so nothing registered in one engine (or one test) is
7
+ // visible to another, and no shared dispatch file has to be edited to add an
8
+ // implementation.
9
+
10
+ export const EXTENSION_KINDS = Object.freeze(['apply-operation', 'proposal-adapter'])
11
+
12
+ // What an apply operation may answer. Anything else is recorded as a failure:
13
+ // an unknown answer is never read as success.
14
+ export const APPLY_RESULT_STATUSES = Object.freeze(['applied', 'apply-unavailable', 'refused', 'conflict', 'failed'])
15
+
16
+ const REQUIRED_METHODS = Object.freeze({
17
+ 'apply-operation': ['apply'],
18
+ 'proposal-adapter': ['propose'],
19
+ })
20
+
21
+ // Until a source apply operation is registered, automatic mode has nothing to
22
+ // call. It says so; it does not pretend an edit was applied.
23
+ export const UNAVAILABLE_APPLY_OPERATION = Object.freeze({
24
+ id: 'atelier.apply-unavailable',
25
+ async apply() {
26
+ return { status: 'apply-unavailable', code: 'no-apply-operation-registered' }
27
+ },
28
+ })
29
+
30
+ export function createMaintenanceExtensions() {
31
+ const registered = new Map()
32
+ return Object.freeze({
33
+ register(kind, implementation) {
34
+ if (!EXTENSION_KINDS.includes(kind)) refuse('unknown-extension-kind', 'only the declared extension kinds can be registered', { kind: String(kind) })
35
+ if (registered.has(kind)) refuse('extension-already-registered', 'an implementation is already registered for this extension kind', { kind })
36
+ const valid = implementation !== null && typeof implementation === 'object' && typeof implementation.id === 'string' && implementation.id !== ''
37
+ && REQUIRED_METHODS[kind].every((method) => typeof implementation[method] === 'function')
38
+ if (!valid) refuse('invalid-extension', `an ${kind} needs an id and its operation`, { kind })
39
+ registered.set(kind, implementation)
40
+ return implementation.id
41
+ },
42
+ get: (kind) => registered.get(kind) ?? null,
43
+ applyOperation: () => registered.get('apply-operation') ?? UNAVAILABLE_APPLY_OPERATION,
44
+ describe: () => EXTENSION_KINDS.map((kind) => ({ kind, id: registered.get(kind)?.id ?? null })),
45
+ })
46
+ }
47
+
48
+ // Normalizes whatever an apply operation returned or threw into one typed
49
+ // result. The engine persists exactly this.
50
+ export function normalizeApplyResult(value) {
51
+ if (value !== null && typeof value === 'object' && APPLY_RESULT_STATUSES.includes(value.status)) {
52
+ return { status: value.status, code: typeof value.code === 'string' ? value.code.slice(0, 120) : value.status }
53
+ }
54
+ return { status: 'failed', code: 'invalid-apply-result' }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Sub-operations of the `obsidian` command, and contributions
59
+ // ---------------------------------------------------------------------------
60
+
61
+ // Later work adds `obsidian <operation>` sub-operations the same way it adds
62
+ // an apply operation: by registering on a value, never by editing the command
63
+ // dispatcher. A built-in name cannot be taken, with one exception: `apply` is
64
+ // a placeholder that only reports `apply-unavailable`, and the work that ships
65
+ // an apply operation replaces it.
66
+ const OPERATION_NAME = /^[a-z][a-z0-9-]{0,31}$/
67
+ // The operations the command ships. The service composes the same registry, so both refuse the same contributions.
68
+ export const BUILT_IN_OPERATIONS = Object.freeze(['status', 'scope', 'audience', 'mode', 'policy', 'service', 'open', 'apply', 'help'])
69
+ export const REPLACEABLE_OPERATIONS = Object.freeze(['apply'])
70
+
71
+ export function createCommandOperations({ reserved = [] } = {}) {
72
+ const registered = new Map()
73
+ return Object.freeze({
74
+ register(operation) {
75
+ const valid = operation !== null && typeof operation === 'object' && typeof operation.name === 'string' && OPERATION_NAME.test(operation.name)
76
+ && typeof operation.summary === 'string' && operation.summary !== '' && typeof operation.run === 'function'
77
+ if (!valid) refuse('invalid-extension', 'a command operation needs a name, a summary and run()')
78
+ // Optional: the shared options of the command this operation takes beyond the ones every operation takes.
79
+ if (operation.options !== undefined && !(Array.isArray(operation.options) && operation.options.every((item) => typeof item === 'string'))) refuse('invalid-extension', 'a command operation declares its options as a list of names')
80
+ if (reserved.includes(operation.name) && !REPLACEABLE_OPERATIONS.includes(operation.name)) refuse('operation-name-reserved', 'a built-in operation cannot be replaced', { name: operation.name })
81
+ if (registered.has(operation.name)) refuse('extension-already-registered', 'an operation of this name is already registered', { name: operation.name })
82
+ registered.set(operation.name, operation)
83
+ return operation.name
84
+ },
85
+ get: (name) => registered.get(name) ?? null,
86
+ describe: () => [...registered.values()].map(({ name, summary }) => ({ name, summary })).sort((left, right) => (left.name < right.name ? -1 : 1)),
87
+ })
88
+ }
89
+
90
+ // One registry per composition: the maintenance extensions the engine reads
91
+ // and the operations the command reads. A contribution is
92
+ // `{ id, register({ extensions, operations }) }`; each is applied once, in order.
93
+ export function createObsidianRegistry({ reservedOperations = BUILT_IN_OPERATIONS, contributions = [] } = {}) {
94
+ const registry = Object.freeze({ extensions: createMaintenanceExtensions(), operations: createCommandOperations({ reserved: reservedOperations }) })
95
+ const applied = []
96
+ for (const contribution of contributions) {
97
+ if (contribution === null || typeof contribution !== 'object' || typeof contribution.id !== 'string' || contribution.id === '' || typeof contribution.register !== 'function') refuse('invalid-extension', 'a contribution needs an id and register()')
98
+ if (applied.includes(contribution.id)) refuse('extension-already-registered', 'a contribution of this id was already applied', { id: contribution.id })
99
+ contribution.register(registry)
100
+ applied.push(contribution.id)
101
+ }
102
+ return Object.freeze({ ...registry, contributions: Object.freeze(applied) })
103
+ }
@@ -0,0 +1,34 @@
1
+ // Continuous maintenance of Obsidian views: typed enablement, private machine
2
+ // settings, observation by digest, pending edits and per-view freshness.
3
+ // The engine starts no process, timer or listener; `tick()` is explicit. The
4
+ // owned lifecycle (start / status / stop of one loopback service per
5
+ // workspace) is separate, below, and is the only thing here that does.
6
+ export { ObsidianMaintenanceRefusal } from './errors.mjs'
7
+ export { DISABLED_REASONS, ENABLEMENT_STATES, readObsidianEnablement } from './enablement.mjs'
8
+ export {
9
+ LOCAL_POINTER_SCHEMA, MACHINE_SETTINGS_SCHEMA, MAINTENANCE_MODES, authorizeAutomaticApply, defaultDataRoot, defaultMachineSettings, ensureWorkspaceIdentity,
10
+ installApplyPolicy, localPointerPath, protectedRoots, readInstalledApplyPolicy, readLocalPointer, readMachineSettings, resolveDataRoot, revokeApplyPolicy, workspaceStateRoot, writeLocalPointer, writeMachineSettings,
11
+ } from './machine-settings.mjs'
12
+ export { APPLY_RESULT_STATUSES, BUILT_IN_OPERATIONS, EXTENSION_KINDS, REPLACEABLE_OPERATIONS, UNAVAILABLE_APPLY_OPERATION, createCommandOperations, createMaintenanceExtensions, createObsidianRegistry } from './extension-points.mjs'
13
+ export { CONTRIBUTIONS_DIRECTORY, loadContributions } from './contributions.mjs'
14
+ export { APP_OUTCOMES, MINIMUM_APP_VERSION, compareAppVersions, createQualifiedAdapterFactory, meetsMinimumAppVersion, parseAppVersion, qualifyApp } from './app-capability.mjs'
15
+ export { OPENING_OUTCOMES, describeOutcome, openScope, scopeReport } from './opening.mjs'
16
+ export {
17
+ CHANGE_CLASSES, CLOSED_EDIT_STATES, EDIT_STATES, FRESHNESS_SCHEMA, FRESHNESS_STATES, LATE_WRITERS_SCHEMA, OPEN_EDIT_STATES, PENDING_EDITS_SCHEMA,
18
+ createMaintenanceStateStore, validateFreshness, validateLateWriters, validatePendingEdits,
19
+ } from './state-store.mjs'
20
+ export { listConfigFiles, listSourceFiles, listVaultNotes, reconcile } from './observation.mjs'
21
+ export { createFsWatcherFactory, createNullWatcherFactory } from './watchers.mjs'
22
+ export { DEFAULT_ELIGIBILITY, assetEligibilityFor, createProductionSeams } from './pipeline.mjs'
23
+ export { DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, DEFAULT_LATE_WRITER_WINDOW_MS, DEFAULT_RETRY_INTERVAL_MS, createMaintenanceEngine } from './engine.mjs'
24
+ export { ENGINE_LOCK_DIRECTORY, LOCK_TICKET_SCHEMA, acquirePrivateGenerationLock, createAbandonmentProof, inspectPrivateGenerationLock } from './private-lock.mjs'
25
+ export { DEFAULT_MAX_BACKOFF_MS, DEFAULT_TICK_INTERVAL_MS, createTickLoop } from './tick-loop.mjs'
26
+ export { HEALTH_SCHEMA, LOOPBACK_HOSTS, probeHealth, requestLoopback } from './service-client.mjs'
27
+ export {
28
+ CONSENT_COVERAGES, SERVICE_ERROR_SCHEMA, SERVICE_SETTINGS_SCHEMA, executableIdentity, publicRecord, readLastServiceError, readServiceRecord, readServiceSettings,
29
+ removeServiceRecord, serviceNameFor, servicePaths, validateServiceRecord, writeServiceRecord, writeServiceSettings,
30
+ } from './service-record.mjs'
31
+ export { MAX_REQUEST_BYTES, SERVICE_OPERATIONS, createServiceServer } from './service-server.mjs'
32
+ export { DEFAULT_SHUTDOWN_GRACE_MS, SERVICE_STATUS_SCHEMA, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
33
+ export { DEFAULT_START_TIMEOUT_MS, DEFAULT_STOP_TIMEOUT_MS, SERVICE_STATES, readServiceStatusDocument, requestServiceTick, serviceStatus, startService, stopService } from './lifecycle.mjs'
34
+ export { STARTUP_PLATFORMS, buildStartupAdapter } from './startup-adapters.mjs'
@@ -0,0 +1,258 @@
1
+ import childProcess from 'node:child_process'
2
+ import { randomBytes as cryptoRandomBytes } from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import net from 'node:net'
5
+ import { isoTime } from './documents.mjs'
6
+ import { refuse } from './errors.mjs'
7
+ import { ensureWorkspaceIdentity } from './machine-settings.mjs'
8
+ import { acquirePrivateGenerationLock, createAbandonmentProof, isProcessAlive } from './private-lock.mjs'
9
+ import { processRunsRecordedExecutable } from './process-identity.mjs'
10
+ import { DEFAULT_PROBE_TIMEOUT_MS, LOOPBACK_HOSTS, probeHealth, requestLoopback } from './service-client.mjs'
11
+ import { SERVICE_ENTRY_PATH } from './service-main.mjs'
12
+ import {
13
+ CONSENT_COVERAGES, SERVICE_SETTINGS_SCHEMA, executableIdentity, openServiceLog, publicRecord, readServiceRecord, readServiceSettings, removeServiceRecord,
14
+ serviceNameFor, servicePaths, writeServiceSettings,
15
+ } from './service-record.mjs'
16
+ import { resolveServiceWorkspace } from './service.mjs'
17
+
18
+ // start / status / stop of the maintenance service of one workspace, as
19
+ // docs/local-services.md describes them.
20
+ //
21
+ // Ownership is proven, never assumed. A running service is ours only when the
22
+ // owner-only record and the health answer agree on service name, workspace,
23
+ // runtime identifier, PID, executable digest and literal loopback address,
24
+ // and that PID is alive. Anything else that answers on the port is
25
+ // `occupied`: it is not adopted, not stopped and not replaced. One case is
26
+ // told apart from it: a listener that accepts and does not answer in time
27
+ // while the recorded PID is alive and provably runs the recorded executable is
28
+ // our own service in a long tick. That is `busy`: never adopted, never
29
+ // stopped and never started over; it is simply asked again later. A record whose
30
+ // address refuses connections is `stale-record` when its PID is gone and
31
+ // `pid-not-ours` when that PID is alive (the number was reused, or a process
32
+ // outlived its listener): such a PID is never signalled.
33
+ //
34
+ // Nothing here looks a process up by port, name or pattern, and nothing here
35
+ // removes anything but the one generated record.
36
+
37
+ export const SERVICE_STATES = Object.freeze(['stopped', 'healthy', 'busy', 'occupied', 'stale-record', 'pid-not-ours'])
38
+ export const DEFAULT_START_TIMEOUT_MS = 20 * 1000
39
+ export const DEFAULT_STOP_TIMEOUT_MS = 45 * 1000
40
+
41
+ const AGREEMENT = ['serviceName', 'workspaceId', 'runtimeId', 'pid', 'host', 'port']
42
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms) })
43
+
44
+ // The decisions the lifecycle oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
45
+ export const LIFECYCLE_PRIMITIVES = Object.freeze({
46
+ // Every field the record and the health answer must agree on; the names of those that differ.
47
+ disagreements(record, health) {
48
+ const differing = AGREEMENT.filter((field) => record[field] !== health[field])
49
+ if (record.executable.digest !== health.executableDigest) differing.push('executableDigest')
50
+ return differing
51
+ },
52
+ // Whether start may report a running service as this workspace's own instead of creating one.
53
+ isOurs: (status) => status.state === 'healthy',
54
+ // Whether start gives up because something unproven answers on the port.
55
+ refusesOccupied: (status) => status.state === 'occupied',
56
+ // Whether a stop may be sent at all.
57
+ mayStop: (status) => status.state === 'healthy',
58
+ // Whether the live recorded PID provably runs the recorded executable, when health did not answer in time.
59
+ provesOurProcess: (record) => processRunsRecordedExecutable(record),
60
+ })
61
+
62
+ function context({ loadProject, dataRoot, env, platform, create = false, randomBytes }) {
63
+ if (typeof loadProject !== 'function') throw new TypeError('the service lifecycle needs loadProject')
64
+ const project = loadProject()
65
+ if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
66
+ const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create })
67
+ return { project, workspace }
68
+ }
69
+
70
+ async function evaluate({ workspaceRoot, workspaceId }, { probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS, alive = isProcessAlive, rules = LIFECYCLE_PRIMITIVES } = {}) {
71
+ // A malformed or foreign record refuses right here, typed; it is never repaired and never adopted.
72
+ const record = readServiceRecord({ workspaceRoot, workspaceId })
73
+ const settings = readServiceSettings({ workspaceRoot, workspaceId })
74
+ const address = record ? { host: record.host, port: record.port } : settings ? { host: settings.host, port: settings.port } : null
75
+ const base = { workspaceId, serviceName: serviceNameFor(workspaceId), address, record, logPath: servicePaths(workspaceRoot).log }
76
+ if (address === null) return { ...base, state: 'stopped', reason: 'never-started' }
77
+ const answer = await probeHealth({ ...address, timeoutMs: probeTimeoutMs })
78
+ if (record === null) return answer.kind === 'refused' ? { ...base, state: 'stopped', reason: 'no-record-and-nothing-listens' } : { ...base, state: 'occupied', reason: 'a-listener-without-a-record', answer: answer.kind }
79
+ if (answer.kind === 'refused') return alive(record.pid) ? { ...base, state: 'pid-not-ours', reason: 'recorded-pid-is-alive-but-nothing-listens' } : { ...base, state: 'stale-record', reason: 'recorded-pid-is-gone' }
80
+ // Only a timeout can be a busy service: a refusal, a foreign answer or a failed connection never is.
81
+ if (answer.kind === 'timeout' && alive(record.pid) && rules.provesOurProcess(record) === true) return { ...base, state: 'busy', reason: 'our-service-did-not-answer-health-in-time', answer: answer.kind }
82
+ if (answer.kind !== 'health') return { ...base, state: 'occupied', reason: answer.kind === 'timeout' ? 'listener-did-not-answer-in-time' : 'listener-is-not-this-service', answer: answer.kind }
83
+ const differing = rules.disagreements(record, answer.body)
84
+ if (differing.length > 0) return { ...base, state: 'occupied', reason: 'health-identity-differs', disagreements: differing }
85
+ if (!alive(record.pid)) return { ...base, state: 'occupied', reason: 'recorded-pid-is-gone-but-something-answers' }
86
+ return { ...base, state: 'healthy', reason: 'record-and-health-agree', health: answer.body }
87
+ }
88
+
89
+ const shown = ({ record, ...status }) => ({ ...status, record: publicRecord(record) })
90
+
91
+ export async function serviceStatus({ loadProject, dataRoot, env = process.env, platform = process.platform, probeTimeoutMs, alive } = {}, rules = LIFECYCLE_PRIMITIVES) {
92
+ const { workspace } = context({ loadProject, dataRoot, env, platform })
93
+ if (!workspace?.workspaceRoot) return { state: 'stopped', reason: 'workspace-not-prepared', workspaceId: workspace?.workspaceId ?? null, record: null, address: null }
94
+ return shown(await evaluate(workspace, { probeTimeoutMs, alive, rules }))
95
+ }
96
+
97
+ // A port nobody listens on right now, chosen once and then recorded, so later starts, status and an installed unit all name the same one.
98
+ function freeLoopbackPort(host) {
99
+ return new Promise((resolve, reject) => {
100
+ const server = net.createServer()
101
+ server.once('error', reject)
102
+ server.listen({ host, port: 0, exclusive: true }, () => { const { port } = server.address(); server.close(() => resolve(port)) })
103
+ })
104
+ }
105
+
106
+ async function resolveSettings({ workspaceRoot, workspaceId }, { host, port, consent, now }) {
107
+ const current = readServiceSettings({ workspaceRoot, workspaceId })
108
+ if (host !== undefined && !LOOPBACK_HOSTS.includes(host)) refuse('service-address-not-loopback', 'the service host must be the literal 127.0.0.1 or ::1')
109
+ if (port !== undefined && (!Number.isInteger(port) || port < 1024 || port > 65535)) refuse('service-address-not-loopback', 'the service port must be between 1024 and 65535')
110
+ if (consent !== undefined && (typeof consent?.actor !== 'string' || !CONSENT_COVERAGES.includes(consent.coverage ?? 'service'))) refuse('startup-consent-required', 'a consent names its actor and what it covers')
111
+ if (current === null && consent === undefined) refuse('startup-consent-required', 'the first start of this workspace needs an explicit consent naming its actor')
112
+ const nextHost = host ?? current?.host ?? '127.0.0.1'
113
+ const next = {
114
+ schema: SERVICE_SETTINGS_SCHEMA, workspaceId, host: nextHost, port: port ?? (current && current.host === nextHost ? current.port : await freeLoopbackPort(nextHost)),
115
+ consent: consent === undefined || (current && current.consent.actor === consent.actor && current.consent.coverage === (consent.coverage ?? 'service')) ? current.consent : { grantedAt: now, actor: consent.actor, coverage: consent.coverage ?? 'service' },
116
+ updatedAt: now,
117
+ }
118
+ const changed = current === null || ['host', 'port'].some((key) => current[key] !== next[key]) || current.consent !== next.consent
119
+ return changed ? writeServiceSettings({ workspaceRoot, workspaceId, settings: next }) : current
120
+ }
121
+
122
+ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
123
+ const {
124
+ loadProject, dataRoot, host, port, consent, detached = false, entryPath = SERVICE_ENTRY_PATH, entryArgs = [], intervalMs,
125
+ startTimeoutMs = DEFAULT_START_TIMEOUT_MS, probeTimeoutMs, clock = () => new Date(), env = process.env, platform = process.platform,
126
+ spawn = childProcess.spawn, execPath = process.execPath, alive = isProcessAlive, randomBytes = cryptoRandomBytes,
127
+ } = options
128
+ const { project, workspace } = context({ loadProject, dataRoot, env, platform, create: true })
129
+ const { workspaceRoot, workspaceId } = workspace
130
+ const deadline = Date.now() + startTimeoutMs
131
+
132
+ // One start at a time per workspace. A start is bounded by its own deadline, so a holder far older than that is gone.
133
+ const proveAbandoned = createAbandonmentProof({ alive, maxAgeMs: startTimeoutMs * 4 })
134
+ let lock
135
+ for (;;) {
136
+ lock = await acquirePrivateGenerationLock({ workspaceRoot, directory: servicePaths(workspaceRoot).startLock, workspaceId, purpose: 'service-start', clock, proveAbandoned })
137
+ if (lock.acquired) break
138
+ const meanwhile = await evaluate(workspace, { probeTimeoutMs, alive, rules })
139
+ if (rules.isOurs(meanwhile)) return { ...shown(meanwhile), started: false, alreadyRunning: true }
140
+ if (meanwhile.state === 'busy') return { ...shown(meanwhile), started: false, alreadyRunning: true, busy: true }
141
+ if (Date.now() >= deadline) refuse('service-start-in-progress', 'another start of this workspace holds the start lock', { reason: lock.reason })
142
+ await sleep(100)
143
+ }
144
+
145
+ let child = null
146
+ try {
147
+ const before = await evaluate(workspace, { probeTimeoutMs, alive, rules })
148
+ if (rules.isOurs(before)) return { ...shown(before), started: false, alreadyRunning: true }
149
+ // Running, and in a long tick: nothing is started beside it and nothing replaces it.
150
+ if (before.state === 'busy') return { ...shown(before), started: false, alreadyRunning: true, busy: true }
151
+ if (rules.refusesOccupied(before)) refuse('service-port-occupied', 'something that is not this service answers on the loopback port; it is never taken over', { reason: before.reason, address: before.address })
152
+ // `stale-record` and `pid-not-ours`: nothing listens, so the recorded runtime is not serving. Its PID is never signalled;
153
+ // the new service replaces the record once it listens. Nothing under recovery or staging is touched on the way.
154
+ const settings = await resolveSettings(workspace, { host, port, consent, now: isoTime(clock) })
155
+ if (before.address && (before.address.host !== settings.host || before.address.port !== settings.port)) {
156
+ const moved = await probeHealth({ host: settings.host, port: settings.port, timeoutMs: probeTimeoutMs })
157
+ if (moved.kind !== 'refused') refuse('service-port-occupied', 'something already answers on the selected loopback port; it is never taken over', { address: { host: settings.host, port: settings.port } })
158
+ }
159
+
160
+ const runtimeId = `rt-${randomBytes(16).toString('hex')}`
161
+ const executable = executableIdentity(entryPath)
162
+ const log = openServiceLog(workspaceRoot)
163
+ try {
164
+ const args = [executable.path, `--project=${project.configPath}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), `--runtime-id=${runtimeId}`, ...(intervalMs === undefined ? [] : [`--interval-ms=${intervalMs}`]), ...entryArgs]
165
+ // No shell. Detached only when the service is meant to outlive the command that starts it.
166
+ child = spawn(execPath, args, { detached, shell: false, windowsHide: true, stdio: ['ignore', log.descriptor, log.descriptor], env })
167
+ } finally {
168
+ fs.closeSync(log.descriptor)
169
+ }
170
+ let exited = null
171
+ child.once('exit', (code, signal) => { exited = { code, signal } })
172
+ child.once('error', (error) => { exited = { code: null, signal: null, error: error.code ?? 'spawn-failed' } })
173
+
174
+ // Health must echo the runtime identifier generated here and the PID of the child created here.
175
+ let last = null
176
+ while (Date.now() < deadline && exited === null) {
177
+ last = await evaluate(workspace, { probeTimeoutMs, alive, rules }).catch((error) => ({ state: 'refused', reason: error.code ?? 'untyped-error' }))
178
+ if (last.state === 'healthy' && last.record.runtimeId === runtimeId && last.record.pid === child.pid) {
179
+ if (detached) child.unref()
180
+ return { ...shown(last), started: true, alreadyRunning: false, ...(detached ? {} : { child }) }
181
+ }
182
+ await sleep(50)
183
+ }
184
+ // The child created here wrote the record and went straight into a long first tick: it runs, and is not stopped.
185
+ if (exited === null && last?.state === 'busy' && last.record?.runtimeId === runtimeId && last.record?.pid === child.pid) {
186
+ if (detached) child.unref()
187
+ return { ...shown(last), started: true, alreadyRunning: false, busy: true, ...(detached ? {} : { child }) }
188
+ }
189
+ // Ownership was never proven: only the child created here is stopped, by its handle.
190
+ if (exited === null) { child.kill(); const until = Date.now() + 5000; while (exited === null && Date.now() < until) await sleep(25) }
191
+ removeServiceRecord({ workspaceRoot, workspaceId, runtimeId, pid: child.pid })
192
+ return { state: 'start-failed', started: false, alreadyRunning: false, workspaceId, reason: exited?.error ?? (exited?.code === null || exited === null ? 'health-never-proved-ownership' : `service-exited-${exited.code}`), observed: last?.state ?? null, logPath: log.file }
193
+ } finally {
194
+ lock.release()
195
+ }
196
+ }
197
+
198
+ export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
199
+ const { loadProject, dataRoot, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive, force = false, kill = process.kill.bind(process) } = options
200
+ const { workspace } = context({ loadProject, dataRoot, env, platform })
201
+ if (!workspace?.workspaceRoot) return { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' }
202
+ const { workspaceRoot, workspaceId } = workspace
203
+ const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
204
+ if (status.state === 'stopped') return { ...shown(status), stopped: false, refused: false }
205
+ // Any disagreement refuses. Nothing is signalled, and the record stays for a person to look at.
206
+ if (status.state === 'busy') return { ...shown(status), stopped: false, refused: true, retry: true, reason: 'service-is-busy-ask-again-later' }
207
+ if (!rules.mayStop(status)) return { ...shown(status), stopped: false, refused: true }
208
+
209
+ const { record } = status
210
+ const answer = await requestLoopback({ host: record.host, port: record.port, method: 'POST', path: '/stop', bearer: record.ext.bearer, payload: { runtimeId: record.runtimeId }, timeoutMs: Math.max(probeTimeoutMs ?? 0, 2 * DEFAULT_PROBE_TIMEOUT_MS) })
211
+ const accepted = answer.kind === 'response' && answer.statusCode === 202 && answer.body?.runtimeId === record.runtimeId && answer.body?.pid === record.pid
212
+ if (!accepted) return { ...shown(status), stopped: false, refused: true, reason: 'stop-was-not-accepted-by-the-proven-runtime' }
213
+ const until = Date.now() + stopTimeoutMs
214
+ while (alive(record.pid) && Date.now() < until) await sleep(50)
215
+ if (alive(record.pid)) {
216
+ // Escalation is opt-in and proven again first: the same runtime and PID must still answer.
217
+ const again = force ? await evaluate(workspace, { probeTimeoutMs, alive, rules }).catch(() => null) : null
218
+ if (!(again?.state === 'healthy' && again.record.runtimeId === record.runtimeId && again.record.pid === record.pid)) return { ...shown(status), stopped: false, refused: true, reason: 'stop-timed-out' }
219
+ kill(record.pid, 'SIGKILL')
220
+ while (alive(record.pid) && Date.now() < until + 5000) await sleep(50)
221
+ }
222
+ // The service removes its own record on the way out; after a hard end it is removed here, and only if it still names that runtime.
223
+ removeServiceRecord({ workspaceRoot, workspaceId, runtimeId: record.runtimeId, pid: record.pid })
224
+ return { state: 'stopped', stopped: true, refused: false, reason: 'stopped-the-proven-runtime', workspaceId, runtimeId: record.runtimeId, pid: record.pid }
225
+ }
226
+
227
+ // One authenticated request to the proven runtime, and to nothing else: anything but a healthy status is answered
228
+ // with that status and no request is made.
229
+ async function askProvenRuntime(options, rules, { method, operation, timeoutMs }) {
230
+ const { loadProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
231
+ const { workspace } = context({ loadProject, dataRoot, env, platform })
232
+ if (!workspace?.workspaceRoot) return { requested: false, state: 'stopped', reason: 'workspace-not-prepared' }
233
+ const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
234
+ if (status.state !== 'healthy') return { requested: false, state: status.state, reason: status.reason }
235
+ const { record } = status
236
+ const answer = await requestLoopback({ host: record.host, port: record.port, method, path: operation, bearer: record.ext.bearer, payload: method === 'POST' ? { runtimeId: record.runtimeId } : null, timeoutMs })
237
+ return { requested: true, state: 'healthy', answer }
238
+ }
239
+
240
+ // Asks the proven runtime for one tick now and returns what that tick reported. A tick that outlasts the wait is
241
+ // `pending`, not an error.
242
+ export async function requestServiceTick(options = {}, rules = LIFECYCLE_PRIMITIVES) {
243
+ const asked = await askProvenRuntime(options, rules, { method: 'POST', operation: '/tick', timeoutMs: options.tickTimeoutMs ?? 60 * 1000 })
244
+ if (!asked.requested) return asked
245
+ const { answer } = asked
246
+ if (answer.kind === 'timeout') return { requested: true, state: 'healthy', pending: true, tick: null, reason: 'tick-still-running' }
247
+ if (answer.kind !== 'response' || answer.statusCode !== 200 || answer.body === null) return { requested: true, state: 'healthy', pending: false, tick: null, reason: 'tick-was-not-answered' }
248
+ return { requested: true, state: 'healthy', pending: false, tick: answer.body, reason: 'tick-ran' }
249
+ }
250
+
251
+ // The status document of the proven runtime: loop, last tick, last error code, freshness summary and, where the
252
+ // service qualifies an app, what it last learned about it. Null when it did not answer.
253
+ export async function readServiceStatusDocument(options = {}, rules = LIFECYCLE_PRIMITIVES) {
254
+ const asked = await askProvenRuntime(options, rules, { method: 'GET', operation: '/status', timeoutMs: options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS })
255
+ if (!asked.requested) return { ...asked, document: null }
256
+ const { answer } = asked
257
+ return { requested: true, state: 'healthy', document: answer.kind === 'response' && answer.statusCode === 200 ? answer.body : null }
258
+ }