@crouton-kit/crouter 0.3.28 → 0.3.29

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 (322) hide show
  1. package/dist/build-root.js +4 -2
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  6. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  7. package/dist/builtin-memory/design.md +1 -1
  8. package/dist/builtin-memory/development.md +1 -1
  9. package/dist/builtin-memory/internal/INDEX.md +1 -1
  10. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  12. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  13. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  14. package/dist/builtin-memory/planning.md +1 -1
  15. package/dist/builtin-memory/spec.md +6 -4
  16. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  17. package/dist/builtin-personas/runtime-base.md +5 -4
  18. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  19. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  20. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  21. package/dist/builtin-views/canvas/core.mjs +586 -0
  22. package/dist/builtin-views/canvas/text.mjs +58 -0
  23. package/dist/builtin-views/canvas/tui.mjs +165 -0
  24. package/dist/builtin-views/canvas/web.jsx +120 -0
  25. package/dist/builtin-views/git-pr/core.mjs +673 -0
  26. package/dist/builtin-views/git-pr/text.mjs +84 -0
  27. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  28. package/dist/builtin-views/git-pr/web.jsx +216 -0
  29. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  30. package/dist/builtin-views/inbox/core.mjs +1273 -0
  31. package/dist/builtin-views/inbox/text.mjs +73 -0
  32. package/dist/builtin-views/inbox/tui.mjs +312 -0
  33. package/dist/builtin-views/inbox/web.jsx +188 -0
  34. package/dist/builtin-views/linkedin/core.mjs +906 -0
  35. package/dist/builtin-views/linkedin/text.mjs +69 -0
  36. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  37. package/dist/builtin-views/linkedin/web.jsx +206 -0
  38. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  39. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  40. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  41. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  42. package/dist/cli.js +3 -3
  43. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  44. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  45. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  46. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  47. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  48. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  49. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  50. package/dist/clients/attach/attach-cmd.js +282 -46
  51. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  52. package/dist/clients/attach/auth-pickers.js +194 -0
  53. package/dist/clients/attach/canvas-panels.d.ts +4 -2
  54. package/dist/clients/attach/canvas-panels.js +45 -17
  55. package/dist/clients/attach/chat-view.d.ts +27 -2
  56. package/dist/clients/attach/chat-view.js +98 -9
  57. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  58. package/dist/clients/attach/clipboard-image.js +150 -26
  59. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  60. package/dist/clients/attach/clipboard-text.js +39 -0
  61. package/dist/clients/attach/config-load.d.ts +54 -7
  62. package/dist/clients/attach/config-load.js +99 -3
  63. package/dist/clients/attach/context-message.d.ts +12 -0
  64. package/dist/clients/attach/context-message.js +72 -0
  65. package/dist/clients/attach/git-info.d.ts +20 -0
  66. package/dist/clients/attach/git-info.js +44 -0
  67. package/dist/clients/attach/graph-overlay.d.ts +13 -4
  68. package/dist/clients/attach/graph-overlay.js +59 -19
  69. package/dist/clients/attach/input-controller.d.ts +75 -13
  70. package/dist/clients/attach/input-controller.js +305 -67
  71. package/dist/clients/attach/pickers.d.ts +58 -0
  72. package/dist/clients/attach/pickers.js +171 -0
  73. package/dist/clients/attach/slash-commands.d.ts +22 -0
  74. package/dist/clients/attach/slash-commands.js +91 -25
  75. package/dist/clients/attach/titled-editor.d.ts +33 -0
  76. package/dist/clients/attach/titled-editor.js +84 -0
  77. package/dist/clients/attach/view-socket.d.ts +18 -1
  78. package/dist/clients/attach/view-socket.js +70 -1
  79. package/dist/clients/web/dev-server.d.ts +7 -0
  80. package/dist/clients/web/dev-server.js +59 -0
  81. package/dist/clients/web/events.d.ts +14 -0
  82. package/dist/clients/web/events.js +151 -0
  83. package/dist/clients/web/server.d.ts +18 -0
  84. package/dist/clients/web/server.js +450 -0
  85. package/dist/clients/web/web-cmd.d.ts +2 -0
  86. package/dist/clients/web/web-cmd.js +120 -0
  87. package/dist/commands/canvas.js +1 -2
  88. package/dist/commands/chord.js +1 -1
  89. package/dist/commands/dashboard.js +5 -1
  90. package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
  91. package/dist/commands/memory/lint.js +14 -2
  92. package/dist/commands/memory/read.js +2 -2
  93. package/dist/commands/memory/shared.d.ts +7 -1
  94. package/dist/commands/memory/shared.js +18 -3
  95. package/dist/commands/memory/write.js +8 -4
  96. package/dist/commands/memory.js +4 -4
  97. package/dist/commands/node.d.ts +1 -1
  98. package/dist/commands/node.js +64 -65
  99. package/dist/commands/pkg.js +1 -2
  100. package/dist/commands/revive.js +11 -3
  101. package/dist/commands/sys/sync.d.ts +1 -0
  102. package/dist/commands/sys/sync.js +187 -0
  103. package/dist/commands/sys.js +3 -2
  104. package/dist/commands/view-cycle.js +2 -2
  105. package/dist/commands/view-list.js +8 -8
  106. package/dist/commands/view-new.js +21 -17
  107. package/dist/commands/view-pick.js +1 -1
  108. package/dist/commands/view-run.js +35 -14
  109. package/dist/commands/view.js +8 -6
  110. package/dist/commands/workspace.d.ts +2 -0
  111. package/dist/commands/workspace.js +161 -0
  112. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  114. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  116. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  117. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  118. package/dist/core/__tests__/close.test.js +14 -20
  119. package/dist/core/__tests__/context-intro.test.js +19 -19
  120. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  121. package/dist/core/__tests__/draw-style.test.js +1 -1
  122. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  123. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  124. package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
  125. package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
  126. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  127. package/dist/core/__tests__/focuses.test.js +25 -13
  128. package/dist/core/__tests__/fork.test.js +22 -5
  129. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  130. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  131. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  132. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  133. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  134. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  135. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  136. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  137. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  138. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  139. package/dist/core/__tests__/helpers/harness.js +19 -36
  140. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  141. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  142. package/dist/core/__tests__/lifecycle.test.js +1 -19
  143. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  144. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  145. package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
  146. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  147. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  148. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  149. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  150. package/dist/core/__tests__/reset.test.js +19 -79
  151. package/dist/core/__tests__/steer-note.test.js +9 -7
  152. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  153. package/dist/core/bootstrap.d.ts +0 -3
  154. package/dist/core/bootstrap.js +1 -143
  155. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  156. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  157. package/dist/core/canvas/browse/app.js +131 -8
  158. package/dist/core/canvas/browse/model.d.ts +23 -5
  159. package/dist/core/canvas/browse/model.js +68 -13
  160. package/dist/core/canvas/browse/render.d.ts +7 -1
  161. package/dist/core/canvas/browse/render.js +178 -47
  162. package/dist/core/canvas/canvas.js +1 -1
  163. package/dist/core/canvas/focuses.d.ts +4 -4
  164. package/dist/core/canvas/focuses.js +7 -7
  165. package/dist/core/canvas/nav-model.js +8 -7
  166. package/dist/core/canvas/render.d.ts +68 -9
  167. package/dist/core/canvas/render.js +195 -61
  168. package/dist/core/canvas/types.d.ts +16 -23
  169. package/dist/core/config.js +1 -4
  170. package/dist/core/memory-resolver.js +43 -5
  171. package/dist/core/runtime/bearings.d.ts +1 -1
  172. package/dist/core/runtime/bearings.js +8 -8
  173. package/dist/core/runtime/branded-host.d.ts +16 -0
  174. package/dist/core/runtime/branded-host.js +127 -0
  175. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  176. package/dist/core/runtime/broker.d.ts +4 -1
  177. package/dist/core/runtime/broker.js +494 -58
  178. package/dist/core/runtime/close.d.ts +12 -2
  179. package/dist/core/runtime/close.js +37 -16
  180. package/dist/core/runtime/front-door.js +6 -14
  181. package/dist/core/runtime/host.d.ts +14 -34
  182. package/dist/core/runtime/host.js +14 -50
  183. package/dist/core/runtime/launch.d.ts +12 -9
  184. package/dist/core/runtime/launch.js +27 -15
  185. package/dist/core/runtime/lifecycle.d.ts +1 -1
  186. package/dist/core/runtime/lifecycle.js +15 -19
  187. package/dist/core/runtime/nodes.d.ts +0 -55
  188. package/dist/core/runtime/nodes.js +6 -74
  189. package/dist/core/runtime/placement.d.ts +91 -335
  190. package/dist/core/runtime/placement.js +262 -840
  191. package/dist/core/runtime/promote.d.ts +2 -0
  192. package/dist/core/runtime/promote.js +24 -6
  193. package/dist/core/runtime/recap.d.ts +8 -0
  194. package/dist/core/runtime/recap.js +107 -0
  195. package/dist/core/runtime/recycle.js +25 -61
  196. package/dist/core/runtime/reset.d.ts +43 -37
  197. package/dist/core/runtime/reset.js +131 -218
  198. package/dist/core/runtime/revive.d.ts +9 -29
  199. package/dist/core/runtime/revive.js +40 -139
  200. package/dist/core/runtime/spawn.d.ts +15 -16
  201. package/dist/core/runtime/spawn.js +165 -194
  202. package/dist/core/runtime/surface-bg.d.ts +11 -0
  203. package/dist/core/runtime/surface-bg.js +68 -0
  204. package/dist/core/runtime/tmux.d.ts +13 -50
  205. package/dist/core/runtime/tmux.js +33 -87
  206. package/dist/core/scope.d.ts +1 -2
  207. package/dist/core/scope.js +1 -5
  208. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  209. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  210. package/dist/core/skill-sync/builtins.d.ts +42 -0
  211. package/dist/core/skill-sync/builtins.js +112 -0
  212. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  213. package/dist/core/skill-sync/claude-plugins.js +71 -0
  214. package/dist/core/skill-sync/engine.d.ts +42 -0
  215. package/dist/core/skill-sync/engine.js +633 -0
  216. package/dist/core/skill-sync/export.d.ts +23 -0
  217. package/dist/core/skill-sync/export.js +86 -0
  218. package/dist/core/skill-sync/manifest.d.ts +64 -0
  219. package/dist/core/skill-sync/manifest.js +181 -0
  220. package/dist/core/skill-sync/profile.d.ts +76 -0
  221. package/dist/core/skill-sync/profile.js +173 -0
  222. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  223. package/dist/core/skill-sync/snapshot.js +120 -0
  224. package/dist/core/substrate/index.d.ts +1 -1
  225. package/dist/core/substrate/index.js +1 -1
  226. package/dist/core/substrate/injected-store.js +3 -3
  227. package/dist/core/substrate/on-read.js +68 -6
  228. package/dist/core/substrate/render.d.ts +8 -11
  229. package/dist/core/substrate/render.js +29 -43
  230. package/dist/core/substrate/schema.d.ts +10 -3
  231. package/dist/core/substrate/schema.js +6 -3
  232. package/dist/core/tui/host.d.ts +12 -4
  233. package/dist/core/tui/host.js +280 -149
  234. package/dist/core/view/bridge.d.ts +10 -0
  235. package/dist/core/view/bridge.js +31 -0
  236. package/dist/core/view/chrome.d.ts +9 -0
  237. package/dist/core/view/chrome.js +22 -0
  238. package/dist/core/view/contract.d.ts +171 -0
  239. package/dist/core/view/contract.js +23 -0
  240. package/dist/core/view/loader.d.ts +31 -0
  241. package/dist/core/view/loader.js +188 -0
  242. package/dist/core/view/transport-local.d.ts +7 -0
  243. package/dist/core/view/transport-local.js +70 -0
  244. package/dist/core/view/transport.d.ts +4 -0
  245. package/dist/core/view/transport.js +15 -0
  246. package/dist/daemon/crtrd.d.ts +38 -27
  247. package/dist/daemon/crtrd.js +303 -376
  248. package/dist/daemon/manage.js +6 -1
  249. package/dist/index.js +1 -1
  250. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  251. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  252. package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
  253. package/dist/pi-extensions/canvas-nav.js +5 -0
  254. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  255. package/dist/pi-extensions/canvas-recap.js +236 -0
  256. package/dist/pi-extensions/canvas-resume.js +2 -1
  257. package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
  258. package/dist/pi-extensions/canvas-stophook.js +87 -101
  259. package/dist/pi-extensions/canvas-view.js +2 -1
  260. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  261. package/dist/pi-extensions/widget-order-bus.js +34 -0
  262. package/dist/prompts/view.d.ts +2 -2
  263. package/dist/prompts/view.js +145 -73
  264. package/dist/types.d.ts +1 -6
  265. package/dist/types.js +1 -3
  266. package/dist/web/ViewChrome.d.ts +7 -0
  267. package/dist/web/ViewChrome.js +28 -0
  268. package/dist/web/ViewPane.d.ts +39 -0
  269. package/dist/web/ViewPane.js +48 -0
  270. package/dist/web/index.d.ts +6 -0
  271. package/dist/web/index.js +16 -0
  272. package/dist/web/runtime.d.ts +39 -0
  273. package/dist/web/runtime.js +133 -0
  274. package/dist/web/states.d.ts +24 -0
  275. package/dist/web/states.js +24 -0
  276. package/dist/web/transport-http.d.ts +5 -0
  277. package/dist/web/transport-http.js +28 -0
  278. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  279. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  280. package/dist/web-client/index.html +13 -0
  281. package/package.json +20 -6
  282. package/dist/builtin-views/canvas/client.mjs +0 -303
  283. package/dist/builtin-views/canvas/view.mjs +0 -576
  284. package/dist/builtin-views/git-pr/client.mjs +0 -440
  285. package/dist/builtin-views/git-pr/view.mjs +0 -675
  286. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  287. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  288. package/dist/builtin-views/inbox/view.mjs +0 -889
  289. package/dist/builtin-views/linkedin/client.mjs +0 -610
  290. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  291. package/dist/commands/pkg/bridge.d.ts +0 -1
  292. package/dist/commands/pkg/bridge.js +0 -137
  293. package/dist/commands/skill/author.d.ts +0 -3
  294. package/dist/commands/skill/author.js +0 -140
  295. package/dist/commands/skill/shared.d.ts +0 -3
  296. package/dist/commands/skill/shared.js +0 -19
  297. package/dist/commands/skill.d.ts +0 -2
  298. package/dist/commands/skill.js +0 -21
  299. package/dist/commands/tmux-spread.d.ts +0 -2
  300. package/dist/commands/tmux-spread.js +0 -144
  301. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  302. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  303. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  304. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  305. package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
  306. package/dist/core/__tests__/home-session.test.js +0 -186
  307. package/dist/core/__tests__/relaunch.test.js +0 -335
  308. package/dist/core/bridge-map.d.ts +0 -19
  309. package/dist/core/bridge-map.js +0 -73
  310. package/dist/core/tui/contract.d.ts +0 -83
  311. package/dist/core/tui/contract.js +0 -8
  312. package/dist/core/tui/loader.d.ts +0 -16
  313. package/dist/core/tui/loader.js +0 -94
  314. package/dist/prompts/skill.d.ts +0 -2
  315. package/dist/prompts/skill.js +0 -650
  316. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  317. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  318. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  319. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  320. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  321. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
  322. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
@@ -0,0 +1,633 @@
1
+ /**
2
+ * Reconcile engine (Phase P6) — the single, per-pair, direction-agnostic
3
+ * reconcile for bidirectional crtr ↔ Claude skill sync.
4
+ *
5
+ * `reconcilePair` is the ONLY reconcile routine (R-U3): there is no forward
6
+ * path + reverse path. Body and asset reconciliation is a symmetric 3-way merge
7
+ * (base = snapshot, ours = crtr, theirs = claude); frontmatter is the only
8
+ * asymmetric axis and all of that asymmetry lives in the translation profile
9
+ * (P5). The snapshot (P3) is the sole change-detector (R-U4): nothing here reads
10
+ * mtimes, fingerprints, or a stamp file.
11
+ *
12
+ * Strictness (R-U6): a conflict — overlapping body/asset merge, a both-sides
13
+ * non-equivalent frontmatter divergence, an asset deleted-on-one ∧ edited-on-the
14
+ * other, or a first-sync divergence with no common base — writes NOTHING for the
15
+ * pair, leaves both endpoints + the snapshot byte-untouched, and surfaces a
16
+ * git-style conflict report. There is no "best-effort" partial write and no
17
+ * silent side-pick.
18
+ *
19
+ * `--dry-run` (R-O1): every merge is computed and the would-be status returned,
20
+ * but no endpoint file, snapshot, or conflict report is touched.
21
+ */
22
+ import { spawnSync } from 'node:child_process';
23
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
24
+ import { homedir, tmpdir } from 'node:os';
25
+ import { dirname, join, relative, sep } from 'node:path';
26
+ import { stringify as stringifyYaml } from 'yaml';
27
+ import { usage } from '../errors.js';
28
+ import { parseFrontmatterGeneric } from '../frontmatter.js';
29
+ import { findPluginByName } from '../resolver.js';
30
+ import { resolveClaudePluginInstallPath } from './claude-plugins.js';
31
+ import { findProjectScopeRoot, pluginMemoryDir, scopeMemoryDir, } from '../scope.js';
32
+ import { SKILLS_DIR, SKILL_ENTRY_FILE } from '../../types.js';
33
+ import { clearConflictReport, readSnapshot, snapshotDir, writeConflictReport, writeSnapshot, } from './snapshot.js';
34
+ /**
35
+ * Reconcile a single enrolled pair (R-E2). Resolves both endpoints, loads (or
36
+ * seeds) the snapshot, 3-way merges body + assets and translate-and-owns the
37
+ * frontmatter, then either writes through cleanly to both sides or reports the
38
+ * conflict and skips. Never throws on a *content* conflict (that is a reported
39
+ * outcome); throws only on a structural/configuration error (an unresolvable
40
+ * endpoint, both endpoints missing) so the caller surfaces it as a hard stop.
41
+ */
42
+ export function reconcilePair(pair, profile, opts) {
43
+ const crtrDir = resolveCrtrBundleDir(pair.crtr);
44
+ const claudeDir = resolveClaudeBundleDir(pair.claude);
45
+ const crtr = readSide(crtrDir);
46
+ const claude = readSide(claudeDir);
47
+ const snapshot = readSnapshot(pair.id);
48
+ if (snapshot === null) {
49
+ return seed(pair, profile, opts, crtr, claude, crtrDir, claudeDir);
50
+ }
51
+ return reconcileWithBase(pair, profile, opts, snapshot, crtr, claude, crtrDir, claudeDir);
52
+ }
53
+ // ── Endpoint resolution (§Resolved-here-6) ───────────────────────────────────
54
+ /** crtr endpoint → its bundle dir `<memoryDir>/<name>/`. */
55
+ function resolveCrtrBundleDir(ep) {
56
+ let memoryDir;
57
+ if (ep.scope === 'plugin') {
58
+ const plugin = findPluginByName(ep.plugin);
59
+ if (!plugin) {
60
+ throw usage(`skill-sync: crtr plugin "${ep.plugin}" not installed`);
61
+ }
62
+ memoryDir = pluginMemoryDir(plugin);
63
+ }
64
+ else if (ep.scope === 'user' || ep.scope === 'project') {
65
+ memoryDir = scopeMemoryDir(ep.scope);
66
+ }
67
+ else {
68
+ throw usage(`skill-sync: scope "${ep.scope}" is not valid for a crtr endpoint ` +
69
+ `(use user, project, or plugin)`);
70
+ }
71
+ if (!memoryDir) {
72
+ throw usage(`skill-sync: no crtr ${ep.scope} memory dir available for "${ep.name}"`);
73
+ }
74
+ return join(memoryDir, ep.name);
75
+ }
76
+ /** Claude endpoint → its bundle dir `<skillsRoot>/<name>/`. The per-scope Claude
77
+ * skills roots: user `~/.claude/skills`, project `<proj>/.claude/skills`, and
78
+ * `claude-plugin` the owning plugin's actual install path
79
+ * `<installPath>/skills` (resolved from Claude's own registry, in place — no
80
+ * user-scope copy). */
81
+ function resolveClaudeBundleDir(ep) {
82
+ let skillsRoot;
83
+ if (ep.scope === 'user') {
84
+ skillsRoot = join(homedir(), '.claude', SKILLS_DIR);
85
+ }
86
+ else if (ep.scope === 'project') {
87
+ const projScopeRoot = findProjectScopeRoot();
88
+ if (!projScopeRoot) {
89
+ throw usage(`skill-sync: no project scope for Claude endpoint "${ep.name}"`);
90
+ }
91
+ // `<proj>/.crouter` → the project dir is its parent; Claude skills sit at
92
+ // `<proj>/.claude/skills`.
93
+ skillsRoot = join(dirname(projScopeRoot), '.claude', SKILLS_DIR);
94
+ }
95
+ else if (ep.scope === 'claude-plugin') {
96
+ skillsRoot = join(resolveClaudePluginInstallPath(ep.plugin), SKILLS_DIR);
97
+ }
98
+ else {
99
+ throw usage(`skill-sync: scope "${ep.scope}" is not valid for a Claude endpoint ` +
100
+ `(use user, project, or claude-plugin)`);
101
+ }
102
+ return join(skillsRoot, ep.name);
103
+ }
104
+ const EMPTY_SIDE = {
105
+ exists: false,
106
+ rawFrontmatter: '',
107
+ frontmatter: null,
108
+ body: '',
109
+ assets: new Map(),
110
+ };
111
+ function readSide(bundleDir) {
112
+ const skillPath = join(bundleDir, SKILL_ENTRY_FILE);
113
+ if (!existsSync(skillPath))
114
+ return { ...EMPTY_SIDE, assets: new Map() };
115
+ const parsed = parseFrontmatterGeneric(readFileSync(skillPath, 'utf8'));
116
+ return {
117
+ exists: true,
118
+ rawFrontmatter: parsed.raw,
119
+ frontmatter: parsed.data,
120
+ body: parsed.body,
121
+ assets: readBundleAssets(bundleDir),
122
+ };
123
+ }
124
+ /** Every file under `bundleDir` EXCEPT the top-level SKILL.md, keyed by POSIX
125
+ * relpath. Mirrors the snapshot store's asset-map key shape. */
126
+ function readBundleAssets(bundleDir) {
127
+ const out = new Map();
128
+ const walk = (cur) => {
129
+ let entries;
130
+ try {
131
+ entries = readdirSync(cur, { withFileTypes: true });
132
+ }
133
+ catch {
134
+ return;
135
+ }
136
+ for (const entry of entries) {
137
+ const full = join(cur, entry.name);
138
+ if (entry.isDirectory()) {
139
+ walk(full);
140
+ }
141
+ else if (entry.isFile()) {
142
+ const rel = relative(bundleDir, full).split(sep).join('/');
143
+ if (rel === SKILL_ENTRY_FILE)
144
+ continue; // the doc, not an asset
145
+ out.set(rel, readFileSync(full));
146
+ }
147
+ }
148
+ };
149
+ walk(bundleDir);
150
+ return out;
151
+ }
152
+ // ── Frontmatter field access ─────────────────────────────────────────────────
153
+ const CRTR_WHENWHY = 'when-and-why-to-read';
154
+ const CRTR_SHORTFORM = 'short-form';
155
+ const CLAUDE_DESC = 'description';
156
+ function strField(rec, key) {
157
+ if (!rec)
158
+ return undefined;
159
+ const v = rec[key];
160
+ if (v === undefined || v === null)
161
+ return undefined;
162
+ return typeof v === 'string' ? v : String(v);
163
+ }
164
+ function readCrtrFm(side) {
165
+ return {
166
+ whenAndWhy: strField(side.frontmatter, CRTR_WHENWHY),
167
+ shortForm: strField(side.frontmatter, CRTR_SHORTFORM),
168
+ };
169
+ }
170
+ function readClaudeDesc(side) {
171
+ return strField(side.frontmatter, CLAUDE_DESC);
172
+ }
173
+ function sameStr(a, b) {
174
+ return (a ?? undefined) === (b ?? undefined);
175
+ }
176
+ function sameCrtrFm(a, b) {
177
+ return sameStr(a.whenAndWhy, b.whenAndWhy) && sameStr(a.shortForm, b.shortForm);
178
+ }
179
+ /**
180
+ * 3-way the single translatable concept (description ↔ when-and-why/short-form)
181
+ * against the recorded `meta.json` base (R-E4). Owned fields are never touched
182
+ * here — each side reads them through, so they appear in neither input nor
183
+ * output (R-X2). Equivalence (OD-1) is round-trip equality through the canonical
184
+ * Claude side.
185
+ */
186
+ function resolveFrontmatter(profile, meta, crtrCur, claudeCur) {
187
+ const baseCrtr = {
188
+ whenAndWhy: meta.crtrFrontmatter.whenAndWhy,
189
+ shortForm: meta.crtrFrontmatter.shortForm,
190
+ };
191
+ const baseClaude = meta.claudeFrontmatter.description;
192
+ const crtrChanged = !sameCrtrFm(crtrCur, baseCrtr);
193
+ const claudeChanged = !sameStr(claudeCur, baseClaude);
194
+ if (!crtrChanged && !claudeChanged) {
195
+ return { crtr: crtrCur, claudeDesc: claudeCur };
196
+ }
197
+ if (crtrChanged && !claudeChanged) {
198
+ // crtr is the sole edit → translate it onto the Claude side.
199
+ return { crtr: crtrCur, claudeDesc: translateCrtrToClaude(profile, crtrCur) };
200
+ }
201
+ if (!crtrChanged && claudeChanged) {
202
+ // Claude is the sole edit → translate it onto the crtr side.
203
+ return { crtr: translateClaudeToCrtr(profile, claudeCur), claudeDesc: claudeCur };
204
+ }
205
+ // Both sides changed — equivalent iff each translated to the canonical Claude
206
+ // side yields a byte-identical string (§Resolved-here-5 / OD-1).
207
+ const crtrAsClaude = translateCrtrToClaude(profile, crtrCur);
208
+ const claudeAsClaude = translateCrtrToClaude(profile, translateClaudeToCrtr(profile, claudeCur));
209
+ if (crtrAsClaude === claudeAsClaude) {
210
+ // Equivalent → no real divergence; keep each side's current value (EC-8).
211
+ return { crtr: crtrCur, claudeDesc: claudeCur };
212
+ }
213
+ return {
214
+ crtr: crtrCur,
215
+ claudeDesc: claudeCur,
216
+ conflict: `frontmatter "${CLAUDE_DESC}" / "${CRTR_WHENWHY}" changed on both sides to ` +
217
+ `non-equivalent values:\n` +
218
+ ` crtr when-and-why-to-read: ${JSON.stringify(crtrCur.whenAndWhy ?? '')}\n` +
219
+ ` crtr short-form: ${JSON.stringify(crtrCur.shortForm ?? '')}\n` +
220
+ ` claude description: ${JSON.stringify(claudeCur ?? '')}`,
221
+ };
222
+ }
223
+ function translateCrtrToClaude(profile, fm) {
224
+ return profile.crtrToClaude.description(fm.whenAndWhy ?? '', fm.shortForm);
225
+ }
226
+ function translateClaudeToCrtr(profile, desc) {
227
+ const { whenAndWhy, shortForm } = profile.claudeToCrtr.description(desc ?? '');
228
+ return { whenAndWhy, shortForm };
229
+ }
230
+ // ── Seeding (first sync — no snapshot) ───────────────────────────────────────
231
+ function seed(pair, profile, opts, crtr, claude, crtrDir, claudeDir) {
232
+ if (crtr.exists && claude.exists) {
233
+ const sharedIdentical = crtr.body === claude.body && assetsEqual(crtr.assets, claude.assets);
234
+ if (sharedIdentical) {
235
+ // R-S1 — endpoints already identical; seed the base, no merge, no-op.
236
+ const meta = buildMeta(readCrtrFm(crtr), readClaudeDesc(claude));
237
+ if (!opts.dryRun) {
238
+ writeSnapshot(pair.id, { body: crtr.body, assets: crtr.assets, meta });
239
+ clearConflictReport(pair.id);
240
+ }
241
+ return { id: pair.id, status: 'noop', wrote: 0 };
242
+ }
243
+ // R-S2 — divergent with no common ancestor → initial conflict, no write.
244
+ if (!opts.dryRun) {
245
+ writeConflictReport(pair.id, buildReport(pair.id, [
246
+ 'first sync: both endpoints exist but their shared content differs, ' +
247
+ 'and there is no snapshot base to merge against. Reconcile the two ' +
248
+ 'sides by hand (make them identical) and re-run.',
249
+ ]));
250
+ }
251
+ return { id: pair.id, status: 'conflict', wrote: 0 };
252
+ }
253
+ if (!crtr.exists && !claude.exists) {
254
+ throw usage(`skill-sync: pair "${pair.id}" has no skill on either side ` +
255
+ `(crtr ${join(crtrDir, SKILL_ENTRY_FILE)} / claude ${join(claudeDir, SKILL_ENTRY_FILE)})`);
256
+ }
257
+ // R-S3 — one-sided bootstrap: materialize the existing side onto the other,
258
+ // translating the new side's frontmatter, and seed the base.
259
+ const crtrIsSource = crtr.exists;
260
+ const source = crtrIsSource ? crtr : claude;
261
+ let crtrFm;
262
+ let claudeDesc;
263
+ if (crtrIsSource) {
264
+ crtrFm = readCrtrFm(source);
265
+ claudeDesc = translateCrtrToClaude(profile, crtrFm);
266
+ }
267
+ else {
268
+ claudeDesc = readClaudeDesc(source);
269
+ crtrFm = translateClaudeToCrtr(profile, claudeDesc);
270
+ }
271
+ const desired = {
272
+ body: source.body,
273
+ assets: source.assets,
274
+ crtrFmInner: frontmatterInner(crtr, crtrFm, true, pair.crtr),
275
+ claudeFmInner: frontmatterInner(claude, claudeDesc, false, pair.claude),
276
+ meta: buildMeta(crtrFm, claudeDesc),
277
+ };
278
+ const wrote = applyAndSnapshot(pair, opts, desired, crtrDir, claudeDir, null);
279
+ return { id: pair.id, status: 'synced', wrote };
280
+ }
281
+ // ── Reconcile against an existing base ───────────────────────────────────────
282
+ function reconcileWithBase(pair, profile, opts, snapshot, crtr, claude, crtrDir, claudeDir) {
283
+ // Whole-bundle presence first: a side whose SKILL.md vanished since the base
284
+ // is a deletion. Propagate it iff the surviving side is unchanged vs base;
285
+ // otherwise it is a delete ∧ edit conflict.
286
+ if (!crtr.exists || !claude.exists) {
287
+ return reconcileDeletion(pair, opts, snapshot, crtr, claude, crtrDir, claudeDir);
288
+ }
289
+ const conflicts = [];
290
+ // Body — both sides present, base is the snapshot body.
291
+ const bodyRes = mergeContent('SKILL.md (body)', Buffer.from(snapshot.body, 'utf8'), Buffer.from(crtr.body, 'utf8'), Buffer.from(claude.body, 'utf8'));
292
+ let mergedBody = snapshot.body;
293
+ if (bodyRes.kind === 'conflict')
294
+ conflicts.push(bodyRes.detail);
295
+ else if (bodyRes.kind === 'present')
296
+ mergedBody = bodyRes.content.toString('utf8');
297
+ // Assets — base-union, per-file 3-way.
298
+ const mergedAssets = new Map();
299
+ for (const rel of unionKeys(snapshot.assets, crtr.assets, claude.assets)) {
300
+ const res = mergeSlot(rel, snapshot.assets.get(rel), crtr.assets.get(rel), claude.assets.get(rel));
301
+ if (res.kind === 'present')
302
+ mergedAssets.set(rel, res.content);
303
+ else if (res.kind === 'conflict')
304
+ conflicts.push(res.detail);
305
+ // 'absent' → deleted on both / clean delete → omit from the merged set.
306
+ }
307
+ // Frontmatter — the single translatable concept, 3-way vs meta.json.
308
+ const fm = resolveFrontmatter(profile, snapshot.meta, readCrtrFm(crtr), readClaudeDesc(claude));
309
+ if (fm.conflict)
310
+ conflicts.push(fm.conflict);
311
+ if (conflicts.length > 0) {
312
+ if (!opts.dryRun)
313
+ writeConflictReport(pair.id, buildReport(pair.id, conflicts));
314
+ return { id: pair.id, status: 'conflict', wrote: 0 };
315
+ }
316
+ const desired = {
317
+ body: mergedBody,
318
+ assets: mergedAssets,
319
+ crtrFmInner: frontmatterInner(crtr, fm.crtr, true, pair.crtr),
320
+ claudeFmInner: frontmatterInner(claude, fm.claudeDesc, false, pair.claude),
321
+ meta: buildMeta(fm.crtr, fm.claudeDesc),
322
+ };
323
+ // Decide synced vs no-op by comparing the desired end-state to what is already
324
+ // on both endpoints AND the snapshot (R-S4 idempotency).
325
+ const counts = applyEndpoints(opts.dryRun, desired, crtrDir, claudeDir, /*write*/ !opts.dryRun);
326
+ const snapDiffers = snapshotDiffers(snapshot, desired);
327
+ const changed = counts.writes > 0 || counts.deletions > 0 || snapDiffers;
328
+ if (!changed) {
329
+ if (!opts.dryRun)
330
+ clearConflictReport(pair.id); // OD-4 — clear any stale report
331
+ return { id: pair.id, status: 'noop', wrote: 0 };
332
+ }
333
+ if (!opts.dryRun) {
334
+ writeSnapshot(pair.id, { body: desired.body, assets: desired.assets, meta: desired.meta });
335
+ clearConflictReport(pair.id); // OD-4
336
+ }
337
+ // `wrote` counts ACTUAL endpoint files written — 0 under --dry-run, even
338
+ // though `counts.writes` above carries the would-be total used for the
339
+ // synced/noop decision (contract: `sys sync -h` promises 0 under --dry-run).
340
+ return { id: pair.id, status: 'synced', wrote: opts.dryRun ? 0 : counts.writes };
341
+ }
342
+ /** Handle the case where at least one side's SKILL.md is gone in a 3-way. */
343
+ function reconcileDeletion(pair, opts, snapshot, crtr, claude, crtrDir, claudeDir) {
344
+ // Both gone → the skill was removed everywhere; clear the base, no-op.
345
+ if (!crtr.exists && !claude.exists) {
346
+ if (!opts.dryRun) {
347
+ rmSync(snapshotDir(pair.id), { recursive: true, force: true });
348
+ clearConflictReport(pair.id);
349
+ }
350
+ return { id: pair.id, status: 'noop', wrote: 0 };
351
+ }
352
+ const surviving = crtr.exists ? crtr : claude;
353
+ const survivingUnchanged = surviving.body === snapshot.body && assetsEqual(surviving.assets, snapshot.assets);
354
+ const survivingDir = crtr.exists ? crtrDir : claudeDir;
355
+ const deletedLabel = crtr.exists ? 'claude' : 'crtr';
356
+ if (survivingUnchanged) {
357
+ // Clean deletion → remove the surviving bundle and the base.
358
+ if (!opts.dryRun) {
359
+ rmSync(survivingDir, { recursive: true, force: true });
360
+ rmSync(snapshotDir(pair.id), { recursive: true, force: true });
361
+ clearConflictReport(pair.id);
362
+ }
363
+ return { id: pair.id, status: 'synced', wrote: 0 };
364
+ }
365
+ // delete ∧ edit → conflict, no write.
366
+ if (!opts.dryRun) {
367
+ writeConflictReport(pair.id, buildReport(pair.id, [
368
+ `the skill was deleted on the ${deletedLabel} side but edited on the other ` +
369
+ `side since the last sync. Resolve by hand (restore or finish the deletion) ` +
370
+ `and re-run.`,
371
+ ]));
372
+ }
373
+ return { id: pair.id, status: 'conflict', wrote: 0 };
374
+ }
375
+ /** Apply the desired state to both endpoints (when `write`), counting files
376
+ * written and deleted across both sides. With `write=false` it only computes
377
+ * the counts (the dry-run path) and touches nothing. */
378
+ function applyEndpoints(_dryRun, desired, crtrDir, claudeDir, write) {
379
+ const a = applyEndpoint(crtrDir, desired.crtrFmInner, desired.body, desired.assets, write);
380
+ const b = applyEndpoint(claudeDir, desired.claudeFmInner, desired.body, desired.assets, write);
381
+ return { writes: a.writes + b.writes, deletions: a.deletions + b.deletions };
382
+ }
383
+ function applyEndpoint(bundleDir, fmInner, body, assets, write) {
384
+ let writes = 0;
385
+ let deletions = 0;
386
+ // SKILL.md
387
+ const skillPath = join(bundleDir, SKILL_ENTRY_FILE);
388
+ const desiredSkill = renderSkill(fmInner, body);
389
+ const curSkill = existsSync(skillPath) ? readFileSync(skillPath, 'utf8') : null;
390
+ if (curSkill !== desiredSkill) {
391
+ writes++;
392
+ if (write) {
393
+ mkdirSync(bundleDir, { recursive: true });
394
+ writeFileSync(skillPath, desiredSkill, 'utf8');
395
+ }
396
+ }
397
+ // Assets: remove on-disk strays not in the desired set, then write the rest.
398
+ const curAssets = existsSync(bundleDir) ? readBundleAssets(bundleDir) : new Map();
399
+ for (const rel of curAssets.keys()) {
400
+ if (!assets.has(rel)) {
401
+ deletions++;
402
+ if (write)
403
+ rmSync(join(bundleDir, ...rel.split('/')), { force: true });
404
+ }
405
+ }
406
+ for (const [rel, buf] of assets) {
407
+ const cur = curAssets.get(rel);
408
+ if (!cur || !cur.equals(buf)) {
409
+ writes++;
410
+ if (write) {
411
+ const full = join(bundleDir, ...rel.split('/'));
412
+ mkdirSync(dirname(full), { recursive: true });
413
+ writeFileSync(full, buf);
414
+ }
415
+ }
416
+ }
417
+ return { writes, deletions };
418
+ }
419
+ /** Seed/materialize helper: apply both endpoints and the snapshot, returning the
420
+ * endpoint files written. `_priorSnapshot` is unused today but documents that
421
+ * callers pass null on a fresh seed. */
422
+ function applyAndSnapshot(pair, opts, desired, crtrDir, claudeDir, _priorSnapshot) {
423
+ const counts = applyEndpoints(opts.dryRun, desired, crtrDir, claudeDir, !opts.dryRun);
424
+ if (!opts.dryRun) {
425
+ writeSnapshot(pair.id, { body: desired.body, assets: desired.assets, meta: desired.meta });
426
+ clearConflictReport(pair.id);
427
+ }
428
+ // Actual writes only — 0 under --dry-run (see reconcileWithBase note).
429
+ return opts.dryRun ? 0 : counts.writes;
430
+ }
431
+ /** A SKILL.md document = its frontmatter block + shared body. */
432
+ function renderSkill(fmInner, body) {
433
+ if (fmInner === '')
434
+ return body;
435
+ return `---\n${fmInner}\n---\n${body}`;
436
+ }
437
+ /**
438
+ * The inner YAML for a side's SKILL.md frontmatter. A side is reserialized ONLY
439
+ * when its translatable value actually changes (or it is being materialized);
440
+ * otherwise its original raw frontmatter block is preserved byte-for-byte so a
441
+ * re-run never normalizes hand-authored formatting into a phantom diff (R-S4).
442
+ */
443
+ function frontmatterInner(side, resolved, isCrtr, ep) {
444
+ if (isCrtr) {
445
+ const target = resolved;
446
+ if (side.exists && sameCrtrFm(target, readCrtrFm(side))) {
447
+ return side.rawFrontmatter; // unchanged → verbatim
448
+ }
449
+ return buildCrtrFrontmatter(side, target);
450
+ }
451
+ const desc = resolved;
452
+ if (side.exists && sameStr(desc, readClaudeDesc(side))) {
453
+ return side.rawFrontmatter; // unchanged → verbatim
454
+ }
455
+ return buildClaudeFrontmatter(side, desc, ep);
456
+ }
457
+ /** Reconstruct the crtr frontmatter record with the resolved translatable
458
+ * fields set, preserving every existing (owned/other) field. A freshly
459
+ * materialized bundle defaults `kind: knowledge` — the valid substrate kind a
460
+ * crtr-side doc must carry (the merged skill+reference kind). */
461
+ function buildCrtrFrontmatter(side, fm) {
462
+ const rec = side.frontmatter ? { ...side.frontmatter } : {};
463
+ if (!side.exists && rec.kind === undefined)
464
+ rec.kind = 'knowledge';
465
+ setOrDelete(rec, CRTR_WHENWHY, fm.whenAndWhy);
466
+ setOrDelete(rec, CRTR_SHORTFORM, fm.shortForm);
467
+ return serializeInner(rec);
468
+ }
469
+ /** Reconstruct the Claude frontmatter record with the resolved description set,
470
+ * preserving every existing (owned/other) field. A freshly materialized bundle
471
+ * carries `name: <ep.name>`. */
472
+ function buildClaudeFrontmatter(side, desc, ep) {
473
+ const rec = side.frontmatter ? { ...side.frontmatter } : {};
474
+ if (!side.exists && rec.name === undefined)
475
+ rec.name = ep.name;
476
+ setOrDelete(rec, CLAUDE_DESC, desc);
477
+ return serializeInner(rec);
478
+ }
479
+ function setOrDelete(rec, key, value) {
480
+ if (value === undefined)
481
+ delete rec[key];
482
+ else
483
+ rec[key] = value;
484
+ }
485
+ /** Serialize a frontmatter record to inner YAML (no trailing newline). */
486
+ function serializeInner(rec) {
487
+ if (Object.keys(rec).length === 0)
488
+ return '';
489
+ return stringifyYaml(rec).replace(/\n$/, '');
490
+ }
491
+ function buildMeta(crtr, claudeDesc) {
492
+ const crtrFrontmatter = {};
493
+ if (crtr.whenAndWhy !== undefined)
494
+ crtrFrontmatter.whenAndWhy = crtr.whenAndWhy;
495
+ if (crtr.shortForm !== undefined)
496
+ crtrFrontmatter.shortForm = crtr.shortForm;
497
+ const claudeFrontmatter = {};
498
+ if (claudeDesc !== undefined)
499
+ claudeFrontmatter.description = claudeDesc;
500
+ return { syncedAt: new Date().toISOString(), crtrFrontmatter, claudeFrontmatter };
501
+ }
502
+ /** True iff the desired shared content or translatable values differ from the
503
+ * snapshot base (syncedAt is bookkeeping and intentionally ignored). */
504
+ function snapshotDiffers(snapshot, desired) {
505
+ if (snapshot.body !== desired.body)
506
+ return true;
507
+ if (!assetsEqual(snapshot.assets, desired.assets))
508
+ return true;
509
+ const m = snapshot.meta;
510
+ if (!sameStr(m.crtrFrontmatter.whenAndWhy, desired.meta.crtrFrontmatter.whenAndWhy))
511
+ return true;
512
+ if (!sameStr(m.crtrFrontmatter.shortForm, desired.meta.crtrFrontmatter.shortForm))
513
+ return true;
514
+ if (!sameStr(m.claudeFrontmatter.description, desired.meta.claudeFrontmatter.description))
515
+ return true;
516
+ return false;
517
+ }
518
+ /** File-level 3-way for one asset relpath, resolving presence by the base-union
519
+ * rule (add-both / delete-both / delete ∧ edit → conflict) then content. */
520
+ function mergeSlot(rel, base, ours, theirs) {
521
+ if (base) {
522
+ if (!ours && !theirs)
523
+ return { kind: 'absent' }; // deleted both sides
524
+ if (!ours) {
525
+ // deleted on crtr
526
+ if (theirs.equals(base))
527
+ return { kind: 'absent' }; // untouched other → delete both
528
+ return { kind: 'conflict', detail: `asset ${rel}: deleted on crtr, edited on claude` };
529
+ }
530
+ if (!theirs) {
531
+ // deleted on claude
532
+ if (ours.equals(base))
533
+ return { kind: 'absent' };
534
+ return { kind: 'conflict', detail: `asset ${rel}: deleted on claude, edited on crtr` };
535
+ }
536
+ return mergeContent(`asset ${rel}`, base, ours, theirs);
537
+ }
538
+ // Not in base.
539
+ if (!ours && !theirs)
540
+ return { kind: 'absent' };
541
+ if (ours && !theirs)
542
+ return { kind: 'present', content: ours }; // added on crtr
543
+ if (!ours && theirs)
544
+ return { kind: 'present', content: theirs }; // added on claude
545
+ if (ours.equals(theirs))
546
+ return { kind: 'present', content: ours }; // added identically
547
+ // Both added divergently → merge against an empty base.
548
+ return mergeContent(`asset ${rel}`, Buffer.alloc(0), ours, theirs);
549
+ }
550
+ /** Content 3-way for a slot present on all three of base/ours/theirs. Text →
551
+ * `git merge-file`; binary (NUL in first 8 KB, §Resolved-here-3) → whole-file
552
+ * identity 3-way, never line-merged (OD-2). */
553
+ function mergeContent(label, base, ours, theirs) {
554
+ if (base.equals(ours) && base.equals(theirs))
555
+ return { kind: 'present', content: ours };
556
+ if (base.equals(ours))
557
+ return { kind: 'present', content: theirs }; // only theirs changed
558
+ if (base.equals(theirs))
559
+ return { kind: 'present', content: ours }; // only ours changed
560
+ if (ours.equals(theirs))
561
+ return { kind: 'present', content: ours }; // both → same value
562
+ if (isBinary(base) || isBinary(ours) || isBinary(theirs)) {
563
+ return { kind: 'conflict', detail: `${label}: binary content changed on both sides` };
564
+ }
565
+ const { merged, conflicts } = mergeFile3(base, ours, theirs);
566
+ if (conflicts) {
567
+ return { kind: 'conflict', detail: `${label}:\n${merged.toString('utf8')}` };
568
+ }
569
+ return { kind: 'present', content: merged };
570
+ }
571
+ /** An asset is binary iff a NUL byte appears in its first 8 KB (§Resolved-here-3). */
572
+ function isBinary(buf) {
573
+ const n = Math.min(buf.length, 8192);
574
+ for (let i = 0; i < n; i++)
575
+ if (buf[i] === 0)
576
+ return true;
577
+ return false;
578
+ }
579
+ /**
580
+ * Synthetic 3-way text merge via `git merge-file -p` (§Resolved-here-2). The
581
+ * three inputs are written to a private temp dir; stdout carries the merged
582
+ * result (with git-style conflict markers when overlapping), and the exit
583
+ * status is the conflict count (0 = clean; ≥128 / 255 / null = a real failure,
584
+ * which throws — never a silent best-effort).
585
+ */
586
+ function mergeFile3(base, ours, theirs) {
587
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-skill-sync-'));
588
+ try {
589
+ const oursPath = join(dir, 'ours');
590
+ const basePath = join(dir, 'base');
591
+ const theirsPath = join(dir, 'theirs');
592
+ writeFileSync(oursPath, ours);
593
+ writeFileSync(basePath, base);
594
+ writeFileSync(theirsPath, theirs);
595
+ const res = spawnSync('git', ['merge-file', '-p', '-L', 'crtr', '-L', 'base', '-L', 'claude', oursPath, basePath, theirsPath], { maxBuffer: 1 << 28 });
596
+ const status = res.status;
597
+ if (res.error || status === null || status < 0 || status >= 128) {
598
+ throw usage(`skill-sync: git merge-file failed (${res.error?.message ?? `status ${status}`})`);
599
+ }
600
+ return { merged: res.stdout, conflicts: status > 0 };
601
+ }
602
+ finally {
603
+ rmSync(dir, { recursive: true, force: true });
604
+ }
605
+ }
606
+ // ── Small utilities ──────────────────────────────────────────────────────────
607
+ function assetsEqual(a, b) {
608
+ if (a.size !== b.size)
609
+ return false;
610
+ for (const [rel, buf] of a) {
611
+ const other = b.get(rel);
612
+ if (!other || !other.equals(buf))
613
+ return false;
614
+ }
615
+ return true;
616
+ }
617
+ function unionKeys(...maps) {
618
+ const out = new Set();
619
+ for (const m of maps)
620
+ for (const k of m.keys())
621
+ out.add(k);
622
+ return out;
623
+ }
624
+ /** Assemble a human-readable conflict report (R-E6 / R-X1). */
625
+ function buildReport(id, entries) {
626
+ const lines = [`# skill-sync conflict: ${id}`, ''];
627
+ lines.push('This pair was NOT written. Resolve the conflicts below by editing the ' +
628
+ 'endpoints, then re-run `crtr sys sync`.', '');
629
+ for (const e of entries) {
630
+ lines.push('---', '', e, '');
631
+ }
632
+ return lines.join('\n');
633
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * One-way export of crtr-generated host artifacts — the sole writer of
3
+ * generated content to `~/.claude/skills`, `~/.claude/commands`, and
4
+ * `~/.pi/agent/prompts`. Replaces the two host-dir writers that lived in
5
+ * bootstrap.ts (`ensureBootSkill` + `ensureSlashCommands`), collapsing their
6
+ * two clobber-guards into one `writeIfOurs` and their two kill-switches
7
+ * (`CRTR_NO_BOOT_SKILL` / `CRTR_NO_MODE_CMDS`) into one `CRTR_NO_EXPORTS`.
8
+ *
9
+ * Triggered per-run by cli.ts (argv-gated, exactly as the old writers were) so
10
+ * first-run provisioning is preserved. Best-effort: a provisioning fault must
11
+ * never break an unrelated `crtr` command (the deliberate, pre-existing
12
+ * behavior of the deleted writers).
13
+ */
14
+ import type { RootDef } from '../command.js';
15
+ /** Write `content` to `path` unless a user-customized file is already there.
16
+ * Rolls forward our own (marker-bearing) versions; skips if identical. Creates
17
+ * only the leaf dir, never the host root. The single generalization of
18
+ * bootstrap.ts's two clobber-guards. */
19
+ export declare function writeIfOurs(path: string, content: string, markerPrefix: string): void;
20
+ /** Render and write every built-in export artifact to every present host.
21
+ * Best-effort: swallows errors (debug-logged) so a provisioning fault never
22
+ * breaks an unrelated `crtr` command. Kill switch: `CRTR_NO_EXPORTS=1`. */
23
+ export declare function provisionExports(root: RootDef, argv?: string[]): void;