@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,803 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { checkBoundaryPolicy, loadBoundaryPolicy } from '../boundary/policy.mjs'
5
+ import { commandProject, resolveProjectConfig } from '../project/config.mjs'
6
+ import { classifyRemoteAuthentication, inspectGitEngine, redactGitDiagnostic, resolveGitExecutable, runGit, sanitizeRemoteUrl } from './git-adapter.mjs'
7
+ import {
8
+ ATELIER_RUNTIME_ENROLLMENT_SCHEMA,
9
+ ATELIER_RUNTIME_PLAN_MAX_AGE_MS,
10
+ ATELIER_RUNTIME_PLAN_MAX_BYTES,
11
+ ATELIER_RUNTIME_PLAN_MAX_FILES,
12
+ acquireRepositoryLock,
13
+ appendOperationTrace,
14
+ atomicWriteJson,
15
+ ensureRuntimeStateRoot,
16
+ readJsonIfPresent,
17
+ readOperationTrace,
18
+ readRuntimeControl,
19
+ removeRuntimeLeaf,
20
+ runtimePlanInventory,
21
+ runtimePaths,
22
+ writeRuntimeControl,
23
+ writeRuntimeState,
24
+ } from './local-state.mjs'
25
+ import { observeRepository, resolveRepositoryRoot } from './repository-observation.mjs'
26
+ import { maintenanceNoticeFor } from './obsidian/sync-notice.mjs'
27
+
28
+ export const ATELIER_COMMIT_PLAN_SCHEMA = 'atelier-commit-plan@v1'
29
+
30
+ const ENROLLMENT_KEYS = ['schema', 'enrolledAt', 'repoRoot', 'projectConfig', 'git', 'mode']
31
+ const ENROLLMENT_GIT_KEYS = ['executable', 'version', 'supported', 'minimum', 'executableSha256']
32
+
33
+ function nowIso() {
34
+ return new Date().toISOString()
35
+ }
36
+
37
+ function hash(value, length = 32) {
38
+ return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, length)
39
+ }
40
+
41
+ function fileSha256(file) {
42
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
43
+ }
44
+
45
+ function stableJson(value) {
46
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
47
+ if (!value || typeof value !== 'object') return JSON.stringify(value)
48
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`
49
+ }
50
+
51
+ function gitFailure(result) {
52
+ return redactGitDiagnostic(result?.stderr?.trim() || result?.error || 'Git operation failed')
53
+ }
54
+
55
+ function normalizePath(value) {
56
+ return String(value || '').replaceAll('\\', '/').replace(/^\.\//, '')
57
+ }
58
+
59
+ function unique(values) {
60
+ return [...new Set(values)]
61
+ }
62
+
63
+ function cleanMessage(value) {
64
+ const message = String(value || '').trim()
65
+ if (!message) throw new Error('commit message is required')
66
+ if (message.length > 500) throw new Error('commit message must not exceed 500 characters')
67
+ if (/[\u0000\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(message)) throw new Error('commit message contains unsupported control characters')
68
+ return message
69
+ }
70
+
71
+ function validateSelectedPaths(paths, observation) {
72
+ const selected = unique((paths || []).map(normalizePath).filter(Boolean)).sort((a, b) => a.localeCompare(b, 'en'))
73
+ if (!selected.length) throw new Error('at least one explicit changed file is required')
74
+ for (const item of selected) {
75
+ if (path.isAbsolute(item) || item === '..' || item.startsWith('../') || item.includes('/../') || item.includes('\0')) {
76
+ throw new Error(`commit path escapes repository: ${item}`)
77
+ }
78
+ }
79
+ const changed = new Set()
80
+ for (const entry of observation.status?.entries || []) {
81
+ changed.add(entry.path)
82
+ if (entry.originalPath) changed.add(entry.originalPath)
83
+ }
84
+ const missing = selected.filter((item) => !changed.has(item))
85
+ if (missing.length) throw new Error(`commit paths are not present in the observed change set: ${missing.join(', ')}`)
86
+ return selected
87
+ }
88
+
89
+ function stagedPaths(observation) {
90
+ const values = []
91
+ for (const entry of observation.status?.entries || []) {
92
+ if (entry.code?.[0] && ![' ', '?', '!'].includes(entry.code[0])) {
93
+ values.push(entry.path)
94
+ if (entry.originalPath) values.push(entry.originalPath)
95
+ }
96
+ }
97
+ return unique(values).sort((a, b) => a.localeCompare(b, 'en'))
98
+ }
99
+
100
+ function attentionState(code, message, observation = null, details = {}) {
101
+ const incidentId = `incident-${hash(JSON.stringify({ code, root: observation?.root, head: observation?.branch?.head, digest: observation?.status?.digest, details }))}`
102
+ return { status: 'attention', code, message, incidentId, observation, details }
103
+ }
104
+
105
+ function healthyState(code, message, observation, details = {}) {
106
+ return { status: 'healthy', code, message, incidentId: null, observation, details }
107
+ }
108
+
109
+ function pausedState(control, observation = null) {
110
+ return { status: 'paused', code: 'user-paused', message: control.reason || 'paused by user', incidentId: null, observation, details: {} }
111
+ }
112
+
113
+ function persistState(paths, state, clock = nowIso) {
114
+ let observation = state.observation
115
+ if (observation?.status) {
116
+ const { schema: sourceSchema, ...observationFields } = observation
117
+ const { entries = [], fingerprints = [], ...status } = observation.status
118
+ observation = {
119
+ ...observationFields,
120
+ sourceSchema,
121
+ status: { ...status, entryCount: entries.length, fingerprintCount: fingerprints.length },
122
+ }
123
+ }
124
+ return writeRuntimeState(paths, { ...state, observation, updatedAt: clock() })
125
+ }
126
+
127
+ function trace(paths, event, clock = nowIso) {
128
+ return appendOperationTrace(paths.trace, { at: clock(), ...event })
129
+ }
130
+
131
+ function enrollmentProjectConfig(repoRoot, value) {
132
+ if (!value) {
133
+ const candidate = path.join(repoRoot, 'atelier.project.json')
134
+ return fs.existsSync(candidate) ? candidate : null
135
+ }
136
+ const resolved = path.resolve(repoRoot, value)
137
+ if (!fs.existsSync(resolved)) throw new Error(`project config not found: ${resolved}`)
138
+ return resolved
139
+ }
140
+
141
+ export function enrollRepository({ repoPath = process.cwd(), projectConfig = null, gitExecutable = null, clock = nowIso } = {}) {
142
+ const selectedGit = gitExecutable || resolveGitExecutable()
143
+ const git = fs.realpathSync.native ? fs.realpathSync.native(selectedGit) : fs.realpathSync(selectedGit)
144
+ const lexicalRepo = fs.realpathSync.native ? fs.realpathSync.native(repoPath) : fs.realpathSync(repoPath)
145
+ let candidateBoundary = lexicalRepo
146
+ let repositoryBoundary = null
147
+ while (true) {
148
+ if (fs.existsSync(path.join(candidateBoundary, '.git'))) {
149
+ repositoryBoundary = candidateBoundary
150
+ break
151
+ }
152
+ if (path.dirname(candidateBoundary) === candidateBoundary) break
153
+ candidateBoundary = path.dirname(candidateBoundary)
154
+ }
155
+ if (repositoryBoundary && isContainedPath(repositoryBoundary, git)) throw new Error('enrollment Git executable must live outside the repository before it can be inspected')
156
+ const resolved = resolveRepositoryRoot(repoPath, git)
157
+ if (!resolved.ok) throw new Error(resolved.error || `unsupported repository root: ${resolved.filesystem?.code || 'unknown'}`)
158
+ if (isContainedPath(resolved.root, git)) throw new Error('enrollment Git executable must live outside the repository')
159
+ const paths = ensureRuntimeStateRoot(resolved.root, git)
160
+ const lock = acquireRepositoryLock(paths, { operation: 'enroll', clock })
161
+ if (!lock.ok) throw new Error('repository is busy with another Atelier operation')
162
+ try {
163
+ const observation = observeRepository({ repoRoot: resolved.root, gitExecutable: git, observedAt: clock() })
164
+ if (observation.bare) throw new Error('bare repositories cannot be enrolled')
165
+ const engine = { ...inspectGitEngine(git), executableSha256: fileSha256(git) }
166
+ if (!engine.supported) throw new Error(`Git ${engine.version} is unsupported; Atelier requires ${engine.minimum} or newer`)
167
+ const enrollment = {
168
+ schema: ATELIER_RUNTIME_ENROLLMENT_SCHEMA,
169
+ enrolledAt: clock(),
170
+ repoRoot: resolved.root,
171
+ projectConfig: enrollmentProjectConfig(resolved.root, projectConfig),
172
+ git: engine,
173
+ mode: 'explicit-single-repository',
174
+ }
175
+ atomicWriteJson(paths.enrollment, enrollment)
176
+ const state = observation.complete
177
+ ? healthyState('enrolled', 'repository enrolled and fully observed', observation)
178
+ : attentionState('repository-incomplete', 'repository enrolled but completeness blockers require review', observation, { blockers: observation.blockers })
179
+ persistState(paths, state, clock)
180
+ trace(paths, { operation: 'enroll', outcome: observation.complete ? 'healthy' : 'attention', details: { root: resolved.root, blockers: observation.blockers.map((item) => item.code) } }, clock)
181
+ return { ok: true, enrollment, state }
182
+ } finally {
183
+ lock.release()
184
+ }
185
+ }
186
+
187
+ function isContainedPath(root, candidate) {
188
+ const relative = path.relative(root, candidate)
189
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
190
+ }
191
+
192
+ function requireClosedObject(value, allowed, label) {
193
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
194
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key))
195
+ if (unknown.length) throw new Error(`${label} contains unsupported fields: ${unknown.join(', ')}`)
196
+ }
197
+
198
+ export function loadEnrollment(repoPath = process.cwd(), { env = process.env } = {}) {
199
+ let root = path.resolve(repoPath)
200
+ try {
201
+ root = fs.realpathSync.native(root)
202
+ } catch {
203
+ root = fs.realpathSync(root)
204
+ }
205
+ const paths = runtimePaths(root)
206
+ try {
207
+ fs.lstatSync(paths.enrollment)
208
+ } catch (error) {
209
+ if (error?.code === 'ENOENT') throw new Error(`repository is not enrolled: ${root}`)
210
+ throw error
211
+ }
212
+ const enrollment = readJsonIfPresent(paths.enrollment)
213
+ if (!enrollment) throw new Error(`repository is not enrolled: ${root}`)
214
+ requireClosedObject(enrollment, ENROLLMENT_KEYS, 'runtime enrollment')
215
+ if (enrollment.schema !== ATELIER_RUNTIME_ENROLLMENT_SCHEMA) throw new Error('runtime enrollment schema is unsupported')
216
+ if (enrollment.mode !== 'explicit-single-repository') throw new Error('runtime enrollment mode is unsupported')
217
+ if (!Number.isFinite(Date.parse(enrollment.enrolledAt))) throw new Error('runtime enrollment timestamp is invalid')
218
+ if (path.resolve(enrollment.repoRoot) !== root) throw new Error('runtime enrollment root does not match the requested repository')
219
+ if (enrollment.projectConfig != null && (!path.isAbsolute(enrollment.projectConfig) || !isContainedPath(root, path.resolve(enrollment.projectConfig)))) {
220
+ throw new Error('runtime enrollment project config escapes the enrolled repository')
221
+ }
222
+ requireClosedObject(enrollment.git, ENROLLMENT_GIT_KEYS, 'runtime enrollment Git identity')
223
+ if (!path.isAbsolute(enrollment.git?.executable || '')) throw new Error('runtime enrollment does not pin an absolute Git executable')
224
+ const expectedGit = resolveGitExecutable({ env })
225
+ if (enrollment.git.executable !== expectedGit) throw new Error('runtime enrollment Git executable does not match the current trusted Git selection')
226
+ if (isContainedPath(root, enrollment.git.executable)) throw new Error('runtime enrollment Git executable must live outside the enrolled repository')
227
+ const engine = { ...inspectGitEngine(expectedGit, { env }), executableSha256: fileSha256(expectedGit) }
228
+ if (stableJson(engine) !== stableJson(enrollment.git)) throw new Error('runtime enrollment Git identity no longer matches the inspected executable')
229
+ if (!engine.supported) throw new Error(`Git ${engine.version} is unsupported; Atelier requires ${engine.minimum} or newer`)
230
+ const ignored = runGit(expectedGit, root, ['check-ignore', '-q', '--', '.atelier-local/runtime/enrollment.json'], { allowFailure: true, env })
231
+ const tracked = runGit(expectedGit, root, ['--literal-pathspecs', 'ls-files', '--error-unmatch', '--', '.atelier-local/runtime/enrollment.json'], { allowFailure: true, env })
232
+ if (!ignored.ok || tracked.ok) throw new Error('runtime enrollment must remain Git-ignored and untracked')
233
+ return { enrollment, paths }
234
+ }
235
+
236
+ export function runtimeStatus({ repoPath = process.cwd(), clock = nowIso } = {}) {
237
+ const { enrollment, paths } = loadEnrollment(repoPath)
238
+ const control = readRuntimeControl(paths)
239
+ const observation = observeRepository({ repoRoot: enrollment.repoRoot, gitExecutable: enrollment.git.executable, observedAt: clock() })
240
+ let state
241
+ if (control.paused) state = pausedState(control, observation)
242
+ else if (!observation.complete) state = attentionState('repository-incomplete', 'repository completeness blockers require review', observation, { blockers: observation.blockers })
243
+ else if (observation.status.conflictCount) state = attentionState('git-conflict', 'repository contains unresolved Git conflicts', observation)
244
+ else if (observation.branch.detached) state = attentionState('detached-head', 'repository is not on a named branch', observation)
245
+ else if (observation.branch.ahead && observation.branch.behind) state = attentionState('branch-diverged', 'local and upstream histories have diverged', observation)
246
+ else if (observation.branch.behind && !observation.status.clean) state = attentionState('update-blocked-by-local-changes', 'upstream changes cannot fast-forward over local work', observation)
247
+ else if (observation.branch.ahead) state = attentionState('local-commits-unpublished', 'local commits are waiting for a user-driven publish action', observation)
248
+ else state = healthyState('observed', 'repository observation is healthy', observation)
249
+ let operations = null
250
+ let traceError = null
251
+ try {
252
+ operations = readOperationTrace(paths.trace)
253
+ } catch (error) {
254
+ traceError = redactGitDiagnostic(error.message)
255
+ }
256
+ return { ok: state.status === 'healthy', enrollment, control, state, traceLength: operations?.length ?? null, traceError }
257
+ }
258
+
259
+ function fetchWithRetries({ enrollment, attempts = 3 }) {
260
+ let last = null
261
+ for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
262
+ last = runGit(enrollment.git.executable, enrollment.repoRoot, ['fetch', '--prune', '--no-tags', '--recurse-submodules=no'], { allowFailure: true, timeout: 60_000 })
263
+ if (last.ok) return { ok: true, attempts: attempt, result: last }
264
+ }
265
+ return { ok: false, attempts: Math.max(1, attempts), result: last }
266
+ }
267
+
268
+ function fullStateOrAttention(enrollment, clock = nowIso) {
269
+ const observation = observeRepository({ repoRoot: enrollment.repoRoot, gitExecutable: enrollment.git.executable, observedAt: clock() })
270
+ if (!observation.complete) return { observation, attention: attentionState('repository-incomplete', 'repository completeness blockers require review', observation, { blockers: observation.blockers }) }
271
+ if (observation.status.conflictCount) return { observation, attention: attentionState('git-conflict', 'repository contains unresolved Git conflicts', observation) }
272
+ if (observation.branch.detached) return { observation, attention: attentionState('detached-head', 'repository is not on a named branch', observation) }
273
+ return { observation, attention: null }
274
+ }
275
+
276
+ export function reconcileRepository({ repoPath = process.cwd(), fetchAttempts = 3, clock = nowIso } = {}) {
277
+ const { enrollment, paths } = loadEnrollment(repoPath)
278
+ const lock = acquireRepositoryLock(paths, { operation: 'reconcile', clock })
279
+ if (!lock.ok) return { ok: false, state: attentionState(lock.code, 'another Atelier operation is using this repository', null, lock.owner) }
280
+ try {
281
+ const control = readRuntimeControl(paths)
282
+ if (control.paused) {
283
+ const state = pausedState(control)
284
+ persistState(paths, state, clock)
285
+ return { ok: false, state }
286
+ }
287
+ const before = fullStateOrAttention(enrollment, clock)
288
+ if (before.attention) {
289
+ persistState(paths, before.attention, clock)
290
+ return { ok: false, state: before.attention }
291
+ }
292
+ const fetched = fetchWithRetries({ enrollment, attempts: fetchAttempts })
293
+ if (!fetched.ok) {
294
+ const state = attentionState('fetch-unavailable', 'Git fetch did not succeed after bounded retries', before.observation, { attempts: fetched.attempts, error: gitFailure(fetched.result) })
295
+ persistState(paths, state, clock)
296
+ trace(paths, { operation: 'reconcile', outcome: 'attention', details: { code: state.code, attempts: fetched.attempts } }, clock)
297
+ return { ok: false, state }
298
+ }
299
+ const after = fullStateOrAttention(enrollment, clock)
300
+ if (after.attention) {
301
+ persistState(paths, after.attention, clock)
302
+ return { ok: false, state: after.attention }
303
+ }
304
+ const observation = after.observation
305
+ let state
306
+ let action = 'no-op'
307
+ if (observation.branch.ahead && observation.branch.behind) {
308
+ state = attentionState('branch-diverged', 'local and upstream histories have diverged', observation)
309
+ } else if (observation.branch.behind && !observation.status.clean) {
310
+ state = attentionState('update-blocked-by-local-changes', 'upstream changes cannot fast-forward over local work', observation)
311
+ } else if (observation.branch.behind) {
312
+ const merged = runGit(enrollment.git.executable, enrollment.repoRoot, ['merge', '--ff-only', '@{upstream}'], { allowFailure: true })
313
+ if (!merged.ok) state = attentionState('fast-forward-refused', 'Git refused a fast-forward-only reconciliation', observation, { error: gitFailure(merged) })
314
+ else {
315
+ action = 'fast-forward'
316
+ const final = fullStateOrAttention(enrollment, clock)
317
+ state = final.attention || healthyState('fast-forwarded', 'repository fast-forwarded to its upstream', final.observation)
318
+ }
319
+ } else if (observation.branch.ahead) {
320
+ state = attentionState('local-commits-unpublished', 'local commits are waiting for a user-driven publish action', observation)
321
+ } else {
322
+ state = healthyState('reconciled', 'repository already matches its upstream', observation)
323
+ }
324
+ persistState(paths, state, clock)
325
+ trace(paths, { operation: 'reconcile', outcome: state.status, details: { action, code: state.code, fetchAttempts: fetched.attempts } }, clock)
326
+ return { ok: state.status !== 'attention', state }
327
+ } catch (error) {
328
+ const state = attentionState('observation-failed', 'repository observation failed during reconciliation', null, { error: redactGitDiagnostic(error.message) })
329
+ persistState(paths, state, clock)
330
+ trace(paths, { operation: 'reconcile', outcome: 'attention', details: { code: state.code } }, clock)
331
+ return { ok: false, state }
332
+ } finally {
333
+ lock.release()
334
+ }
335
+ }
336
+
337
+ function publishTarget(observation) {
338
+ const upstream = observation.branch?.upstream
339
+ if (!upstream) return null
340
+ const remotes = [...(observation.remotes || [])].sort((left, right) => right.name.length - left.name.length)
341
+ const remote = remotes.find((item) => upstream === item.name || upstream.startsWith(`${item.name}/`))
342
+ if (!remote || upstream === remote.name) return null
343
+ return {
344
+ remote: remote.name,
345
+ branch: upstream.slice(remote.name.length + 1),
346
+ url: remote.pushUrl,
347
+ identityDigest: remote.pushIdentityDigest,
348
+ authentication: remote.pushAuthentication,
349
+ }
350
+ }
351
+
352
+ function currentPushDestination(enrollment, remoteName) {
353
+ const result = runGit(enrollment.git.executable, enrollment.repoRoot, ['remote', 'get-url', '--push', '--all', remoteName], { allowFailure: true })
354
+ if (!result.ok) throw new Error('publish destination could not be resolved; commit remains local and was not published')
355
+ const urls = result.stdout.split(/\r?\n/).map((value) => value.trim()).filter(Boolean)
356
+ if (urls.length !== 1) throw new Error('publish destination is ambiguous; commit remains local and was not published')
357
+ return urls[0]
358
+ }
359
+
360
+ function diffSummary(gitExecutable, root, selected) {
361
+ const tracked = runGit(gitExecutable, root, ['--literal-pathspecs', 'diff', '--numstat', '--', ...selected], { allowFailure: true })
362
+ const lines = tracked.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
363
+ const [added, deleted, ...name] = line.split('\t')
364
+ return { path: normalizePath(name.join('\t')), added: added === '-' ? null : Number(added), deleted: deleted === '-' ? null : Number(deleted) }
365
+ })
366
+ const covered = new Set(lines.map((item) => item.path))
367
+ for (const item of selected) {
368
+ if (covered.has(item)) continue
369
+ const absolute = path.join(root, item)
370
+ if (fs.existsSync(absolute) && fs.statSync(absolute).isFile()) lines.push({ path: item, added: null, deleted: null, bytes: fs.statSync(absolute).size, untracked: true })
371
+ }
372
+ return lines.sort((left, right) => left.path.localeCompare(right.path, 'en'))
373
+ }
374
+
375
+ function reviewedManifest(observation, selected) {
376
+ return selected.map((itemPath) => {
377
+ const entry = (observation.status?.fingerprints || []).find((item) => item.path === itemPath || item.originalPath === itemPath)
378
+ if (!entry) throw new Error(`review fingerprint is unavailable for ${itemPath}`)
379
+ return {
380
+ path: itemPath,
381
+ worktree: entry.path === itemPath && entry.worktree
382
+ ? { mode: entry.worktree.mode, blob: entry.worktree.indexBlob }
383
+ : null,
384
+ }
385
+ })
386
+ }
387
+
388
+ function planAuthority(plan) {
389
+ return {
390
+ schema: plan.schema,
391
+ mode: plan.mode,
392
+ createdAt: plan.createdAt,
393
+ repoRoot: plan.repoRoot,
394
+ gitExecutable: plan.gitExecutable,
395
+ observedHead: plan.observedHead,
396
+ observedBranch: plan.observedBranch,
397
+ observedStatusDigest: plan.observedStatusDigest,
398
+ paths: plan.paths,
399
+ message: plan.message,
400
+ diff: plan.diff,
401
+ reviewedManifest: plan.reviewedManifest,
402
+ publish: plan.publish,
403
+ }
404
+ }
405
+
406
+ function operationIdFor(plan) {
407
+ return `operation-${hash(stableJson(planAuthority(plan)), 64)}`
408
+ }
409
+
410
+ function sameToken(left, right) {
411
+ const first = Buffer.from(String(left || ''))
412
+ const second = Buffer.from(String(right || ''))
413
+ return first.length === second.length && crypto.timingSafeEqual(first, second)
414
+ }
415
+
416
+ function validateStoredPlan(plan, operationId, confirmation, currentAt = nowIso()) {
417
+ const allowed = [
418
+ 'schema', 'operationId', 'mode', 'createdAt', 'repoRoot', 'gitExecutable',
419
+ 'observedHead', 'observedBranch', 'observedStatusDigest', 'paths', 'message',
420
+ 'diff', 'reviewedManifest', 'publish', 'confirmation',
421
+ ]
422
+ if (!plan || typeof plan !== 'object' || Array.isArray(plan)) throw new Error(`commit plan not found: ${operationId}`)
423
+ const unknown = Object.keys(plan).filter((key) => !allowed.includes(key))
424
+ if (unknown.length) throw new Error(`commit plan contains unsupported fields: ${unknown.join(', ')}`)
425
+ if (plan.schema !== ATELIER_COMMIT_PLAN_SCHEMA || plan.mode !== 'user-confirmed') throw new Error('commit plan schema or mode is unsupported')
426
+ const createdMs = Date.parse(plan.createdAt)
427
+ const currentMs = Date.parse(currentAt)
428
+ if (!Number.isFinite(createdMs) || !Number.isFinite(currentMs)) throw new Error('commit plan timestamp is invalid')
429
+ if (createdMs > currentMs + 60_000 || currentMs - createdMs > ATELIER_RUNTIME_PLAN_MAX_AGE_MS) throw new Error('commit plan expired; create a new commit plan')
430
+ const expected = operationIdFor(plan)
431
+ if (!sameToken(expected, operationId) || !sameToken(plan.operationId, operationId)) throw new Error('commit plan content does not match its operation id')
432
+ if (plan.confirmation?.required !== true || !sameToken(plan.confirmation?.operationId, confirmation)) throw new Error('commit plan is not eligible for user-confirmed execution')
433
+ const expectedInstruction = `Run atelier sync commit --operation ${operationId} --confirm ${operationId}`
434
+ if (plan.confirmation?.instruction !== expectedInstruction) throw new Error('commit plan confirmation instruction is invalid')
435
+ if (plan.repoRoot == null || !path.isAbsolute(plan.repoRoot) || !path.isAbsolute(plan.gitExecutable || '')) throw new Error('commit plan repository or Git identity is invalid')
436
+ if (!Array.isArray(plan.paths) || !Array.isArray(plan.reviewedManifest)) throw new Error('commit plan path evidence is invalid')
437
+ return plan
438
+ }
439
+
440
+ function stagedManifest(gitExecutable, root, selected) {
441
+ return selected.map((itemPath) => {
442
+ const result = runGit(gitExecutable, root, ['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', itemPath])
443
+ const records = result.stdout.split('\0').filter(Boolean)
444
+ if (!records.length) return { path: itemPath, worktree: null }
445
+ if (records.length !== 1) throw new Error(`staged path has unresolved index stages: ${itemPath}`)
446
+ const match = records[0].match(/^(\d{6}) ([0-9a-f]{40,64}) 0\t/)
447
+ if (!match) throw new Error(`staged path evidence is malformed: ${itemPath}`)
448
+ return { path: itemPath, worktree: { mode: match[1], blob: match[2] } }
449
+ })
450
+ }
451
+
452
+ function treeManifest(gitExecutable, root, tree, selected) {
453
+ return selected.map((itemPath) => {
454
+ const result = runGit(gitExecutable, root, ['--literal-pathspecs', 'ls-tree', '-z', tree, '--', itemPath])
455
+ const records = result.stdout.split('\0').filter(Boolean)
456
+ if (!records.length) return { path: itemPath, worktree: null }
457
+ if (records.length !== 1) throw new Error(`reviewed tree path evidence is ambiguous: ${itemPath}`)
458
+ const match = records[0].match(/^(\d{6}) blob ([0-9a-f]{40,64})\t/)
459
+ if (!match) throw new Error(`reviewed tree path evidence is malformed: ${itemPath}`)
460
+ return { path: itemPath, worktree: { mode: match[1], blob: match[2] } }
461
+ })
462
+ }
463
+
464
+ function treeChangedPaths(gitExecutable, root, base, tree) {
465
+ const result = runGit(gitExecutable, root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', base, tree])
466
+ return unique(result.stdout.split('\0').filter(Boolean).map(normalizePath)).sort((left, right) => left.localeCompare(right, 'en'))
467
+ }
468
+
469
+ function pruneAndBoundPlans(paths, currentAt) {
470
+ const now = Date.parse(currentAt)
471
+ if (!Number.isFinite(now)) throw new Error('commit plan timestamp is invalid')
472
+ let inventory = runtimePlanInventory(paths.plans)
473
+ for (const item of inventory.files) {
474
+ let createdAt = item.mtimeMs
475
+ if (now - createdAt > ATELIER_RUNTIME_PLAN_MAX_AGE_MS) {
476
+ removeRuntimeLeaf(item.file)
477
+ continue
478
+ }
479
+ try {
480
+ const stored = readJsonIfPresent(item.file)
481
+ const parsed = Date.parse(stored?.createdAt)
482
+ if (Number.isFinite(parsed)) createdAt = parsed
483
+ } catch (error) {
484
+ if (/redirected|regular file|escapes workspace/.test(error.message)) throw error
485
+ // Malformed or oversized retained state cannot carry confirmation
486
+ // authority. Removing it under the repository lock is fail-closed and
487
+ // lets valid planning recover without a manual filesystem repair.
488
+ removeRuntimeLeaf(item.file)
489
+ continue
490
+ }
491
+ if (now - createdAt > ATELIER_RUNTIME_PLAN_MAX_AGE_MS) removeRuntimeLeaf(item.file)
492
+ }
493
+ inventory = runtimePlanInventory(paths.plans)
494
+ if (inventory.files.length >= ATELIER_RUNTIME_PLAN_MAX_FILES || inventory.bytes >= ATELIER_RUNTIME_PLAN_MAX_BYTES) {
495
+ throw new Error('runtime commit plan storage reached its resident ceiling; remove or consume existing plans')
496
+ }
497
+ }
498
+
499
+ export function planUserConfirmedCommit({
500
+ repoPath = process.cwd(),
501
+ paths: selectedPaths,
502
+ message,
503
+ publish = false,
504
+ fetchAttempts = 3,
505
+ clock = nowIso,
506
+ } = {}) {
507
+ const { enrollment, paths } = loadEnrollment(repoPath)
508
+ const lock = acquireRepositoryLock(paths, { operation: 'plan-user-confirmed-commit', clock })
509
+ if (!lock.ok) throw new Error('repository is busy with another Atelier operation')
510
+ try {
511
+ const control = readRuntimeControl(paths)
512
+ if (control.paused) throw new Error(`repository is paused: ${control.reason || 'paused by user'}`)
513
+ let observed = fullStateOrAttention(enrollment, clock)
514
+ if (observed.attention) throw new Error(observed.attention.message)
515
+ if (publish) {
516
+ if (observed.observation.branch.ahead > 0) throw new Error('cannot prepare a publish plan while prior local commits are unpublished')
517
+ const fetched = fetchWithRetries({ enrollment, attempts: fetchAttempts })
518
+ if (!fetched.ok) throw new Error(`cannot prepare a publish plan because fetch failed: ${gitFailure(fetched.result)}`)
519
+ observed = fullStateOrAttention(enrollment, clock)
520
+ if (observed.attention) throw new Error(observed.attention.message)
521
+ }
522
+ const observation = observed.observation
523
+ if (observation.status.conflictCount) throw new Error('cannot plan a commit while conflicts exist')
524
+ if (observation.branch.ahead && observation.branch.behind) throw new Error('cannot plan a commit on a diverged branch')
525
+ if (observation.branch.behind) throw new Error('cannot plan a commit while the branch is behind its upstream')
526
+ if (publish && observation.branch.ahead > 0) throw new Error('cannot prepare a publish plan while prior local commits are unpublished')
527
+ if (observation.status.stagedCount) throw new Error('pre-existing staged changes must be committed or unstaged before Atelier can prepare a bounded change set')
528
+ const selected = validateSelectedPaths(selectedPaths, observation)
529
+ const target = publishTarget(observation)
530
+ if (publish && !target) throw new Error('publish was requested but the current branch has no supported upstream target')
531
+ const commitMessage = cleanMessage(message)
532
+ const createdAt = clock()
533
+ pruneAndBoundPlans(paths, createdAt)
534
+ const plan = {
535
+ schema: ATELIER_COMMIT_PLAN_SCHEMA,
536
+ mode: 'user-confirmed',
537
+ createdAt,
538
+ repoRoot: enrollment.repoRoot,
539
+ gitExecutable: enrollment.git.executable,
540
+ observedHead: observation.branch.head,
541
+ observedBranch: observation.branch.branch,
542
+ observedStatusDigest: observation.status.digest,
543
+ paths: selected,
544
+ message: commitMessage,
545
+ diff: diffSummary(enrollment.git.executable, enrollment.repoRoot, selected),
546
+ reviewedManifest: reviewedManifest(observation, selected),
547
+ publish: publish ? target : null,
548
+ }
549
+ const operationId = operationIdFor(plan)
550
+ plan.operationId = operationId
551
+ plan.confirmation = {
552
+ required: true,
553
+ operationId,
554
+ instruction: `Run atelier sync commit --operation ${operationId} --confirm ${operationId}`,
555
+ }
556
+ const encodedBytes = Buffer.byteLength(`${JSON.stringify(plan, null, 2)}\n`)
557
+ const inventory = runtimePlanInventory(paths.plans)
558
+ if (encodedBytes > ATELIER_RUNTIME_PLAN_MAX_BYTES || inventory.bytes + encodedBytes > ATELIER_RUNTIME_PLAN_MAX_BYTES) {
559
+ throw new Error('runtime commit plan storage would exceed its resident byte ceiling')
560
+ }
561
+ atomicWriteJson(path.join(paths.plans, `${operationId}.json`), plan)
562
+ trace(paths, { operation: 'commit-plan-created', operationId, outcome: 'awaiting-user-confirmation', mode: 'user-confirmed', details: { paths: selected, publish: Boolean(plan.publish) } }, clock)
563
+ return { ok: true, plan }
564
+ } finally {
565
+ lock.release()
566
+ }
567
+ }
568
+
569
+ function projectBoundaryCheck(enrollment) {
570
+ if (!enrollment.projectConfig) return { ok: true, configured: false, report: null }
571
+ const project = commandProject({ argv: ['--project', enrollment.projectConfig], cwd: enrollment.repoRoot, gitExecutable: enrollment.git.executable })
572
+ const scannedRepoRoots = (project.repos || [])
573
+ .filter((repo) => !repo.external && repo.path)
574
+ .map((repo) => {
575
+ try { return fs.realpathSync.native ? fs.realpathSync.native(repo.path) : fs.realpathSync(repo.path) } catch { return path.resolve(repo.path) }
576
+ })
577
+ if (!scannedRepoRoots.includes(enrollment.repoRoot)) {
578
+ return { ok: false, configured: true, scannedRepoRoots, report: { errors: [{ code: 'boundary-scope-mismatch', message: 'configured boundary policy does not include the enrolled repository' }] } }
579
+ }
580
+ const loaded = loadBoundaryPolicy(project)
581
+ if (!loaded.ok) return { ok: false, configured: true, scannedRepoRoots, report: { errors: loaded.errors.map((message) => ({ code: 'boundary-policy-missing', message })) } }
582
+ const report = checkBoundaryPolicy({
583
+ project,
584
+ policy: loaded.policy,
585
+ staged: true,
586
+ stagedOnly: true,
587
+ gitExecutable: enrollment.git.executable,
588
+ allowNetworkActorResolution: false,
589
+ allowHistoryActorResolution: false,
590
+ forceActorErrors: true,
591
+ })
592
+ return { ok: report.ok, configured: true, scannedRepoRoots, report }
593
+ }
594
+
595
+ function restoreIndex(gitExecutable, root) {
596
+ runGit(gitExecutable, root, ['reset', '--mixed', '--quiet', 'HEAD'], { allowFailure: true })
597
+ }
598
+
599
+ function normalizeCommitMessage(value) {
600
+ return String(value || '').replaceAll('\r\n', '\n').replace(/\n+$/, '')
601
+ }
602
+
603
+ function rollbackCommit(gitExecutable, root, priorHead, createdCommit) {
604
+ const rolledBack = runGit(gitExecutable, root, ['update-ref', 'HEAD', priorHead, createdCommit], { allowFailure: true })
605
+ if (!rolledBack.ok) return false
606
+ restoreIndex(gitExecutable, root)
607
+ return true
608
+ }
609
+
610
+ export function executeUserConfirmedCommit({ repoPath = process.cwd(), operationId, confirmation, clock = nowIso } = {}) {
611
+ if (!/^operation-[0-9a-f]{64}$/.test(String(operationId || '')) || !sameToken(confirmation, operationId)) throw new Error('exact operation confirmation token is required')
612
+ const { enrollment, paths } = loadEnrollment(repoPath)
613
+ const planFile = path.join(paths.plans, `${operationId}.json`)
614
+ const lock = acquireRepositoryLock(paths, { operation: `execute-${operationId}`, clock })
615
+ if (!lock.ok) throw new Error('repository is busy with another Atelier operation')
616
+ let staged = false
617
+ try {
618
+ const executionAt = clock()
619
+ const plan = validateStoredPlan(readJsonIfPresent(planFile), operationId, confirmation, executionAt)
620
+ if (plan.repoRoot !== enrollment.repoRoot || plan.gitExecutable !== enrollment.git.executable) throw new Error('commit plan does not match the current enrollment')
621
+ const control = readRuntimeControl(paths)
622
+ if (control.paused) throw new Error(`repository is paused: ${control.reason || 'paused by user'}`)
623
+ const observed = fullStateOrAttention(enrollment, clock)
624
+ if (observed.attention) throw new Error(observed.attention.message)
625
+ const observation = observed.observation
626
+ if (observation.branch.head !== plan.observedHead || observation.branch.branch !== plan.observedBranch || observation.status.digest !== plan.observedStatusDigest) {
627
+ throw new Error('repository changed after review; create a new commit plan')
628
+ }
629
+ if (observation.status.stagedCount) throw new Error('repository gained staged changes after review; create a new commit plan')
630
+ validateSelectedPaths(plan.paths, observation)
631
+ if (plan.publish && stableJson(publishTarget(observation)) !== stableJson(plan.publish)) throw new Error('publish target changed after review; create a new commit plan')
632
+ staged = true
633
+ runGit(enrollment.git.executable, enrollment.repoRoot, ['--literal-pathspecs', 'add', '--', ...plan.paths])
634
+ const stagedObservation = observeRepository({ repoRoot: enrollment.repoRoot, gitExecutable: enrollment.git.executable, observedAt: clock() })
635
+ const actualStaged = stagedPaths(stagedObservation)
636
+ const expectedStaged = unique(plan.paths).sort((a, b) => a.localeCompare(b, 'en'))
637
+ if (JSON.stringify(actualStaged) !== JSON.stringify(expectedStaged)) {
638
+ throw new Error(`staged change set does not match the reviewed plan: expected ${expectedStaged.join(', ')}, got ${actualStaged.join(', ')}`)
639
+ }
640
+ const actualManifest = stagedManifest(enrollment.git.executable, enrollment.repoRoot, expectedStaged)
641
+ if (stableJson(actualManifest) !== stableJson(plan.reviewedManifest)) throw new Error('staged bytes or modes do not match the reviewed plan')
642
+ const boundary = projectBoundaryCheck(enrollment)
643
+ if (!boundary.ok) {
644
+ const messages = (boundary.report?.errors || []).map((item) => item.message).join('; ')
645
+ throw new Error(`boundary validation refused the commit: ${messages || 'unknown boundary failure'}`)
646
+ }
647
+ const expectedTree = runGit(enrollment.git.executable, enrollment.repoRoot, ['write-tree']).stdout.trim()
648
+ const expectedTreePaths = treeChangedPaths(enrollment.git.executable, enrollment.repoRoot, plan.observedHead, expectedTree)
649
+ if (JSON.stringify(expectedTreePaths) !== JSON.stringify(expectedStaged)) {
650
+ throw new Error(`reviewed tree change set drifted before commit: expected ${expectedStaged.join(', ')}, got ${expectedTreePaths.join(', ')}`)
651
+ }
652
+ const expectedTreeManifest = treeManifest(enrollment.git.executable, enrollment.repoRoot, expectedTree, expectedStaged)
653
+ if (stableJson(expectedTreeManifest) !== stableJson(plan.reviewedManifest)) throw new Error('reviewed tree bytes or modes drifted before commit')
654
+ const committed = runGit(enrollment.git.executable, enrollment.repoRoot, ['commit', '-m', plan.message], { allowFailure: true, allowPrompt: true })
655
+ if (!committed.ok) {
656
+ const headAfterFailure = runGit(enrollment.git.executable, enrollment.repoRoot, ['rev-parse', 'HEAD'], { allowFailure: true }).stdout.trim()
657
+ if (headAfterFailure && headAfterFailure !== plan.observedHead) {
658
+ const rolledBack = rollbackCommit(enrollment.git.executable, enrollment.repoRoot, plan.observedHead, headAfterFailure)
659
+ staged = false
660
+ if (!rolledBack) {
661
+ const state = attentionState('commit-rollback-failed', 'Git commit failed after HEAD moved and automatic rollback could not prove recovery', null, { operationId, headAfterFailure })
662
+ persistState(paths, state, clock)
663
+ throw new Error('Git commit failed after HEAD moved; automatic rollback failed and repository attention is required')
664
+ }
665
+ throw new Error('Git hook moved HEAD while the reviewed commit failed; the unexpected commit was rolled back')
666
+ }
667
+ throw new Error(`Git commit failed: ${gitFailure(committed)}`)
668
+ }
669
+ const commit = runGit(enrollment.git.executable, enrollment.repoRoot, ['rev-parse', 'HEAD']).stdout.trim()
670
+ const committedTree = runGit(enrollment.git.executable, enrollment.repoRoot, ['rev-parse', `${commit}^{tree}`]).stdout.trim()
671
+ const parentFields = runGit(enrollment.git.executable, enrollment.repoRoot, ['rev-list', '--parents', '-n', '1', commit]).stdout.trim().split(/\s+/)
672
+ const committedMessage = runGit(enrollment.git.executable, enrollment.repoRoot, ['show', '-s', '--format=%B', commit]).stdout
673
+ const postCommitFailures = []
674
+ if (committedTree !== expectedTree) postCommitFailures.push('tree')
675
+ if (parentFields.length !== 2 || parentFields[1] !== plan.observedHead) postCommitFailures.push('parent')
676
+ if (normalizeCommitMessage(committedMessage) !== normalizeCommitMessage(plan.message)) postCommitFailures.push('message')
677
+ if (postCommitFailures.length) {
678
+ const rolledBack = rollbackCommit(enrollment.git.executable, enrollment.repoRoot, plan.observedHead, commit)
679
+ staged = false
680
+ if (!rolledBack) {
681
+ const state = attentionState('commit-rollback-failed', 'Git altered reviewed commit authority and automatic rollback could not prove recovery', null, { operationId, commit, failures: postCommitFailures })
682
+ persistState(paths, state, clock)
683
+ throw new Error(`Git altered reviewed commit ${postCommitFailures.join(', ')}; automatic rollback failed and repository attention is required`)
684
+ }
685
+ throw new Error(`Git hook or concurrent change altered the reviewed commit ${postCommitFailures.join(', ')}; commit was rolled back`)
686
+ }
687
+ staged = false
688
+ trace(paths, { operation: 'commit-created', operationId, outcome: 'committed', mode: 'user-confirmed', details: { commit, paths: plan.paths, boundaryConfigured: boundary.configured, boundaryRepoRoots: boundary.scannedRepoRoots || [] } }, clock)
689
+ let publish = null
690
+ if (plan.publish) {
691
+ const beforePublish = fullStateOrAttention(enrollment, clock)
692
+ if (beforePublish.attention) throw new Error(`repository became incomplete before publish: ${beforePublish.attention.message}`)
693
+ if (stableJson(publishTarget(beforePublish.observation)) !== stableJson(plan.publish)) throw new Error('publish target changed after review; commit remains local and was not published')
694
+ if (beforePublish.observation.branch.head !== commit) throw new Error('repository HEAD changed after the reviewed commit; commit remains local and was not published')
695
+ const pushDestination = currentPushDestination(enrollment, plan.publish.remote)
696
+ const sanitizedPushDestination = sanitizeRemoteUrl(pushDestination)
697
+ const executionTarget = {
698
+ remote: plan.publish.remote,
699
+ branch: plan.publish.branch,
700
+ url: sanitizedPushDestination,
701
+ identityDigest: crypto.createHash('sha256').update(sanitizedPushDestination).digest('hex'),
702
+ authentication: classifyRemoteAuthentication(pushDestination),
703
+ }
704
+ if (stableJson(executionTarget) !== stableJson(plan.publish)) throw new Error('publish target changed at execution; commit remains local and was not published')
705
+ const pushed = runGit(enrollment.git.executable, enrollment.repoRoot, ['push', '--no-follow-tags', '--recurse-submodules=no', '--', pushDestination, `${commit}:refs/heads/${plan.publish.branch}`], { allowFailure: true, allowPrompt: true, timeout: 120_000 })
706
+ publish = { ok: pushed.ok, remote: plan.publish.remote, branch: plan.publish.branch, error: pushed.ok ? null : gitFailure(pushed) }
707
+ trace(paths, { operation: 'commit-publish', operationId, outcome: pushed.ok ? 'published' : 'attention', mode: 'user-confirmed', details: { commit, ...publish } }, clock)
708
+ if (!pushed.ok) {
709
+ const observationAfterCommit = observeRepository({ repoRoot: enrollment.repoRoot, gitExecutable: enrollment.git.executable, observedAt: clock() })
710
+ const state = attentionState('publish-failed', 'commit was created locally but Git push failed', observationAfterCommit, publish)
711
+ persistState(paths, state, clock)
712
+ return { ok: false, committed: true, commit, publish, state }
713
+ }
714
+ const reviewedRemote = beforePublish.observation.remotes.find((item) => item.name === plan.publish.remote)
715
+ const sameFetchAndPushIdentity = reviewedRemote?.identityDigest === plan.publish.identityDigest
716
+ if (!sameFetchAndPushIdentity) {
717
+ const observationAfterPublish = observeRepository({ repoRoot: enrollment.repoRoot, gitExecutable: enrollment.git.executable, observedAt: clock() })
718
+ const state = attentionState(
719
+ 'published-to-distinct-push-target',
720
+ 'commit was published, but the configured fetch upstream is a distinct destination and cannot be marked synchronized',
721
+ observationAfterPublish,
722
+ { operationId, commit, remote: plan.publish.remote, branch: plan.publish.branch },
723
+ )
724
+ persistState(paths, state, clock)
725
+ return { ok: false, committed: true, commit, publish, state }
726
+ }
727
+ const trackingRef = `refs/remotes/${plan.publish.remote}/${plan.publish.branch}`
728
+ const refreshed = runGit(
729
+ enrollment.git.executable,
730
+ enrollment.repoRoot,
731
+ [
732
+ 'fetch',
733
+ '--no-tags',
734
+ '--no-write-fetch-head',
735
+ '--recurse-submodules=no',
736
+ '--',
737
+ pushDestination,
738
+ `refs/heads/${plan.publish.branch}:${trackingRef}`,
739
+ ],
740
+ { allowFailure: true, allowPrompt: true, timeout: 120_000 },
741
+ )
742
+ if (!refreshed.ok) {
743
+ const observationAfterPublish = observeRepository({ repoRoot: enrollment.repoRoot, gitExecutable: enrollment.git.executable, observedAt: clock() })
744
+ const state = attentionState(
745
+ 'publish-tracking-refresh-failed',
746
+ 'commit was published, but the local remote-tracking ref could not be refreshed',
747
+ observationAfterPublish,
748
+ { operationId, commit, remote: plan.publish.remote, branch: plan.publish.branch, error: gitFailure(refreshed) },
749
+ )
750
+ persistState(paths, state, clock)
751
+ return { ok: false, committed: true, commit, publish, state }
752
+ }
753
+ }
754
+ const final = fullStateOrAttention(enrollment, clock)
755
+ const state = final.attention || healthyState(plan.publish ? 'committed-and-published' : 'committed-locally', plan.publish ? 'reviewed change set was committed and published' : 'reviewed change set was committed locally', final.observation, { operationId, commit })
756
+ persistState(paths, state, clock)
757
+ return { ok: state.status !== 'attention', committed: true, commit, publish, state }
758
+ } catch (error) {
759
+ if (staged) restoreIndex(enrollment.git.executable, enrollment.repoRoot)
760
+ trace(paths, { operation: 'commit-execution', operationId, outcome: 'refused', mode: 'user-confirmed', details: { reason: redactGitDiagnostic(error.message) } }, clock)
761
+ throw error
762
+ } finally {
763
+ try {
764
+ removeRuntimeLeaf(planFile)
765
+ } finally {
766
+ lock.release()
767
+ }
768
+ }
769
+ }
770
+
771
+ export function setRepositoryPaused({ repoPath = process.cwd(), paused, reason = null, clock = nowIso } = {}) {
772
+ const { paths } = loadEnrollment(repoPath)
773
+ const lock = acquireRepositoryLock(paths, { operation: paused ? 'pause' : 'resume', clock })
774
+ if (!lock.ok) throw new Error('repository is busy with another Atelier operation')
775
+ try {
776
+ const control = writeRuntimeControl(paths, { paused, reason, updatedAt: clock() })
777
+ const state = paused ? pausedState(control) : healthyState('resumed', 'repository supervision resumed', null)
778
+ persistState(paths, state, clock)
779
+ trace(paths, { operation: paused ? 'pause' : 'resume', outcome: state.status, details: { reason: control.reason } }, clock)
780
+ return { ok: true, control, state }
781
+ } finally {
782
+ lock.release()
783
+ }
784
+ }
785
+
786
+ export function operationTrace({ repoPath = process.cwd() } = {}) {
787
+ const { paths } = loadEnrollment(repoPath)
788
+ return readOperationTrace(paths.trace)
789
+ }
790
+
791
+ // Report only. Git synchronization cannot maintain an external Obsidian vault and does not try: when the enrolled
792
+ // project enables that integration, `sync` repeats what maintenance last persisted and points at its own command.
793
+ // Null when the project is not configured for it or has it off, and then the output of `sync` is unchanged.
794
+ export function obsidianMaintenanceNotice({ repoPath = process.cwd(), env = process.env } = {}) {
795
+ try {
796
+ const { enrollment } = loadEnrollment(repoPath, { env })
797
+ if (!enrollment.projectConfig) return null
798
+ return maintenanceNoticeFor(resolveProjectConfig({ argv: [`--project=${enrollment.projectConfig}`], cwd: enrollment.repoRoot, env, writeLocalState: false }), { env })
799
+ } catch {
800
+ // A report must never change what sync does or whether it succeeds.
801
+ return null
802
+ }
803
+ }