@narumitw/pi-sync 0.51.0 → 0.52.0

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 (181) hide show
  1. package/README.md +30 -5
  2. package/dist/chunks/{chunk-7KHKWL3Q.ts → chunk-2CBF6SMT.ts} +8 -7
  3. package/dist/chunks/chunk-2CBF6SMT.ts.map +7 -0
  4. package/dist/chunks/{chunk-YFADF5ZQ.ts → chunk-2DI6PDZV.ts} +5 -1
  5. package/dist/chunks/chunk-2DI6PDZV.ts.map +7 -0
  6. package/dist/chunks/chunk-2GUXI2XT.ts +1702 -0
  7. package/dist/chunks/chunk-2GUXI2XT.ts.map +7 -0
  8. package/dist/chunks/{chunk-4MPIH46N.ts → chunk-3B4Z2SKV.ts} +40 -6
  9. package/dist/chunks/chunk-3B4Z2SKV.ts.map +7 -0
  10. package/dist/chunks/{chunk-YPXGU5OR.ts → chunk-4QVPTIZB.ts} +16 -6
  11. package/dist/chunks/{chunk-YPXGU5OR.ts.map → chunk-4QVPTIZB.ts.map} +2 -2
  12. package/dist/chunks/{chunk-UA7RXOIT.ts → chunk-4SBTAVFL.ts} +6 -6
  13. package/dist/chunks/{chunk-6546TS54.ts → chunk-5RZPINUQ.ts} +7 -4
  14. package/dist/chunks/{chunk-6546TS54.ts.map → chunk-5RZPINUQ.ts.map} +2 -2
  15. package/dist/chunks/chunk-76NB4R7U.ts +107 -0
  16. package/dist/chunks/chunk-76NB4R7U.ts.map +7 -0
  17. package/dist/chunks/{chunk-DM4X4FIA.ts → chunk-7D6RDCVC.ts} +8 -10
  18. package/dist/chunks/{chunk-DM4X4FIA.ts.map → chunk-7D6RDCVC.ts.map} +2 -2
  19. package/dist/chunks/chunk-A6HU4ERM.ts +124 -0
  20. package/dist/chunks/chunk-A6HU4ERM.ts.map +7 -0
  21. package/dist/chunks/chunk-AMJRUXFT.ts +17 -0
  22. package/dist/chunks/chunk-AMJRUXFT.ts.map +7 -0
  23. package/dist/chunks/chunk-ANWNQIR7.ts +1013 -0
  24. package/dist/chunks/chunk-ANWNQIR7.ts.map +7 -0
  25. package/dist/chunks/{chunk-T56RNRZJ.ts → chunk-BDB4IDM3.ts} +19 -10
  26. package/dist/chunks/chunk-BDB4IDM3.ts.map +7 -0
  27. package/dist/chunks/{chunk-2RZSQWBH.ts → chunk-CHSIAC4N.ts} +5 -5
  28. package/dist/chunks/{chunk-2RZSQWBH.ts.map → chunk-CHSIAC4N.ts.map} +2 -2
  29. package/dist/chunks/chunk-HUZ5CR4W.ts +110 -0
  30. package/dist/chunks/chunk-HUZ5CR4W.ts.map +7 -0
  31. package/dist/chunks/{chunk-QLSN7AGX.ts → chunk-IJ6VV6FN.ts} +81 -19
  32. package/dist/chunks/chunk-IJ6VV6FN.ts.map +7 -0
  33. package/dist/chunks/chunk-ORQRHEEO.ts +362 -0
  34. package/dist/chunks/chunk-ORQRHEEO.ts.map +7 -0
  35. package/dist/chunks/chunk-QE7NZZWT.ts +380 -0
  36. package/dist/chunks/chunk-QE7NZZWT.ts.map +7 -0
  37. package/dist/chunks/{chunk-HGJH56KR.ts → chunk-QHSQCK7L.ts} +4 -4
  38. package/dist/chunks/chunk-QOFMXPM5.ts +735 -0
  39. package/dist/chunks/chunk-QOFMXPM5.ts.map +7 -0
  40. package/dist/chunks/{chunk-DZUSTGQV.ts → chunk-UQ3ALBIB.ts} +4 -4
  41. package/dist/chunks/{chunk-Q32CQWPM.ts → chunk-V6VZ5TLG.ts} +13 -6
  42. package/dist/chunks/chunk-V6VZ5TLG.ts.map +7 -0
  43. package/dist/chunks/{chunk-E42GXD6D.ts → chunk-WKPX2SIC.ts} +4 -4
  44. package/dist/chunks/chunk-ZW5ZLGBI.ts +154 -0
  45. package/dist/chunks/chunk-ZW5ZLGBI.ts.map +7 -0
  46. package/dist/chunks/{chunk-6NT3YFGY.ts → chunk-ZX2Q6THB.ts} +8 -3
  47. package/dist/chunks/{chunk-6NT3YFGY.ts.map → chunk-ZX2Q6THB.ts.map} +2 -2
  48. package/dist/chunks/conflict-artifacts-BB2FZMIE.ts +27 -0
  49. package/dist/chunks/conflict-review-T3PB7ZSD.ts +175 -0
  50. package/dist/chunks/conflict-review-T3PB7ZSD.ts.map +7 -0
  51. package/dist/chunks/{file-selection-RAVHRYFG.ts → file-selection-WZX7HAY2.ts} +8 -7
  52. package/dist/chunks/{file-selection-RAVHRYFG.ts.map → file-selection-WZX7HAY2.ts.map} +1 -1
  53. package/dist/chunks/{git-backend-IAANDL3K.ts → git-backend-HLGKUQJT.ts} +19 -7
  54. package/dist/chunks/git-backend-HLGKUQJT.ts.map +7 -0
  55. package/dist/chunks/local-fields-6DGYOOIK.ts +22 -0
  56. package/dist/chunks/manager-result-dispatcher-VIDT3QBU.ts +23 -0
  57. package/dist/chunks/{manager-ui-763NEKJY.ts → manager-ui-HSMHS3OK.ts} +236 -57
  58. package/dist/chunks/manager-ui-HSMHS3OK.ts.map +7 -0
  59. package/dist/chunks/merge-baseline-store-EVQAAML6.ts +22 -0
  60. package/dist/chunks/remote-selection-ui-SMF5CLFO.ts +22 -0
  61. package/dist/chunks/{s3-backend-XGXUOPUN.ts → s3-backend-DJ2NT3SZ.ts} +16 -9
  62. package/dist/chunks/s3-backend-DJ2NT3SZ.ts.map +7 -0
  63. package/dist/chunks/{setup-switch-5G2VNJHL.ts → setup-switch-KTUWL6DE.ts} +9 -6
  64. package/dist/chunks/{setup-wizard-YIEWGX32.ts → setup-wizard-GSDPDFC4.ts} +10 -7
  65. package/dist/chunks/{setup-wizard-YIEWGX32.ts.map → setup-wizard-GSDPDFC4.ts.map} +1 -1
  66. package/dist/chunks/{snapshot-JL5DFSXI.ts → snapshot-THS5EY2L.ts} +6 -9
  67. package/dist/chunks/snapshot-THS5EY2L.ts.map +7 -0
  68. package/dist/chunks/{sync-inspection-P3ENJLNF.ts → sync-inspection-VBJF2FWC.ts} +10 -10
  69. package/dist/chunks/sync-inspection-VBJF2FWC.ts.map +7 -0
  70. package/dist/chunks/{sync-operations-AR76EB6C.ts → sync-operations-27VQUDH2.ts} +309 -508
  71. package/dist/chunks/sync-operations-27VQUDH2.ts.map +7 -0
  72. package/dist/chunks/{sync-state-VHJUZ4YG.ts → sync-state-CFL4RRRB.ts} +6 -6
  73. package/dist/chunks/sync-state-CFL4RRRB.ts.map +7 -0
  74. package/dist/chunks/{webdav-backend-3EGUONAT.ts → webdav-backend-ETQGSI6I.ts} +13 -7
  75. package/dist/chunks/webdav-backend-ETQGSI6I.ts.map +7 -0
  76. package/dist/index.ts +142 -43
  77. package/dist/index.ts.map +2 -2
  78. package/docs/content-merge-audit.md +156 -0
  79. package/docs/merge-implementation-audit.md +312 -0
  80. package/docs/settings-merge-audit.md +153 -0
  81. package/docs/settings.md +36 -0
  82. package/package.json +6 -6
  83. package/src/backends/git/git-backend.ts +2 -0
  84. package/src/backends/git/git-storage.ts +8 -3
  85. package/src/backends/s3/s3-backend.ts +2 -1
  86. package/src/backends/webdav/webdav-backend.ts +2 -1
  87. package/src/commands/command-execution.ts +10 -0
  88. package/src/commands/command.ts +4 -0
  89. package/src/settings/config.ts +12 -0
  90. package/src/settings/settings-management.ts +2 -2
  91. package/src/settings/settings-types.ts +14 -1
  92. package/src/settings/settings-validation.ts +24 -3
  93. package/src/snapshot/session-paths.ts +34 -7
  94. package/src/snapshot/snapshot-apply.ts +81 -32
  95. package/src/snapshot/snapshot-case-replacement.ts +80 -0
  96. package/src/snapshot/snapshot-codec.ts +6 -1
  97. package/src/snapshot/snapshot-root-transition.ts +154 -0
  98. package/src/snapshot/snapshot-transaction-plan.ts +72 -0
  99. package/src/snapshot/snapshot-transaction.ts +844 -102
  100. package/src/snapshot/snapshot-types.ts +5 -0
  101. package/src/state/json-file.ts +32 -5
  102. package/src/state/merge-baseline-store.ts +182 -0
  103. package/src/state/mutation-directory-sync.ts +34 -0
  104. package/src/state/state-types.ts +4 -0
  105. package/src/state/sync-state-store.ts +14 -1
  106. package/src/sync/automatic-sync.ts +11 -3
  107. package/src/sync/conflict-artifact-ownership.ts +63 -0
  108. package/src/sync/conflict-artifacts.ts +195 -0
  109. package/src/sync/conflict-groups.ts +68 -0
  110. package/src/sync/conflict-preview.ts +56 -0
  111. package/src/sync/conflict-resolution.ts +93 -0
  112. package/src/sync/conflict-retention.ts +72 -0
  113. package/src/sync/conflict-review.ts +95 -0
  114. package/src/sync/content-conflicts.ts +73 -0
  115. package/src/sync/field-migration.ts +39 -0
  116. package/src/sync/file-merge-planner.ts +143 -0
  117. package/src/sync/json-document.ts +184 -0
  118. package/src/sync/local-fields.ts +134 -0
  119. package/src/sync/merge-apply.ts +380 -0
  120. package/src/sync/merge-journal.ts +219 -0
  121. package/src/sync/merged-sync.ts +723 -0
  122. package/src/sync/partial-progress.ts +54 -0
  123. package/src/sync/remote-snapshot.ts +6 -0
  124. package/src/sync/session-merge.ts +194 -0
  125. package/src/sync/settings-conflicts.ts +52 -0
  126. package/src/sync/settings-merge.ts +57 -0
  127. package/src/sync/startup-check.ts +90 -5
  128. package/src/sync/sync-inspection.ts +1 -0
  129. package/src/sync/sync-local.ts +61 -0
  130. package/src/sync/sync-mutations.ts +256 -114
  131. package/src/sync/sync-queries.ts +6 -2
  132. package/src/sync/sync-state.ts +14 -5
  133. package/src/sync/text-merge.ts +133 -0
  134. package/src/sync-extension.ts +8 -0
  135. package/src/ui/manager-state.ts +12 -1
  136. package/src/ui/manager-ui.ts +10 -0
  137. package/src/ui/merge-review.ts +28 -0
  138. package/src/ui/remote-selection-ui.ts +1 -0
  139. package/src/ui/settings-ui.ts +183 -2
  140. package/src/ui/setup/setup-actions.ts +2 -2
  141. package/src/ui/setup/setup-switcher.ts +2 -1
  142. package/src/ui/storage-connections-ui.ts +11 -7
  143. package/src/ui/sync-attention.ts +3 -0
  144. package/src/ui/sync-format.ts +16 -12
  145. package/src/ui/sync-setups-ui.ts +1 -0
  146. package/src/ui/terminal-text.ts +9 -2
  147. package/dist/chunks/chunk-4AIHXRKE.ts +0 -197
  148. package/dist/chunks/chunk-4AIHXRKE.ts.map +0 -7
  149. package/dist/chunks/chunk-4MPIH46N.ts.map +0 -7
  150. package/dist/chunks/chunk-7KHKWL3Q.ts.map +0 -7
  151. package/dist/chunks/chunk-EEJPYTWE.ts +0 -348
  152. package/dist/chunks/chunk-EEJPYTWE.ts.map +0 -7
  153. package/dist/chunks/chunk-IVXEDPAD.ts +0 -370
  154. package/dist/chunks/chunk-IVXEDPAD.ts.map +0 -7
  155. package/dist/chunks/chunk-MSSW6F3R.ts +0 -12
  156. package/dist/chunks/chunk-MSSW6F3R.ts.map +0 -7
  157. package/dist/chunks/chunk-OVDTF5CH.ts +0 -90
  158. package/dist/chunks/chunk-OVDTF5CH.ts.map +0 -7
  159. package/dist/chunks/chunk-Q32CQWPM.ts.map +0 -7
  160. package/dist/chunks/chunk-QLSN7AGX.ts.map +0 -7
  161. package/dist/chunks/chunk-SWUXA3MH.ts +0 -18
  162. package/dist/chunks/chunk-SWUXA3MH.ts.map +0 -7
  163. package/dist/chunks/chunk-T56RNRZJ.ts.map +0 -7
  164. package/dist/chunks/chunk-YFADF5ZQ.ts.map +0 -7
  165. package/dist/chunks/git-backend-IAANDL3K.ts.map +0 -7
  166. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +0 -23
  167. package/dist/chunks/manager-ui-763NEKJY.ts.map +0 -7
  168. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +0 -22
  169. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +0 -7
  170. package/dist/chunks/sync-operations-AR76EB6C.ts.map +0 -7
  171. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +0 -7
  172. /package/dist/chunks/{chunk-UA7RXOIT.ts.map → chunk-4SBTAVFL.ts.map} +0 -0
  173. /package/dist/chunks/{chunk-HGJH56KR.ts.map → chunk-QHSQCK7L.ts.map} +0 -0
  174. /package/dist/chunks/{chunk-DZUSTGQV.ts.map → chunk-UQ3ALBIB.ts.map} +0 -0
  175. /package/dist/chunks/{chunk-E42GXD6D.ts.map → chunk-WKPX2SIC.ts.map} +0 -0
  176. /package/dist/chunks/{manager-result-dispatcher-OTUMQM26.ts.map → conflict-artifacts-BB2FZMIE.ts.map} +0 -0
  177. /package/dist/chunks/{remote-selection-ui-TZTJF32Y.ts.map → local-fields-6DGYOOIK.ts.map} +0 -0
  178. /package/dist/chunks/{setup-switch-5G2VNJHL.ts.map → manager-result-dispatcher-VIDT3QBU.ts.map} +0 -0
  179. /package/dist/chunks/{snapshot-JL5DFSXI.ts.map → merge-baseline-store-EVQAAML6.ts.map} +0 -0
  180. /package/dist/chunks/{sync-inspection-P3ENJLNF.ts.map → remote-selection-ui-SMF5CLFO.ts.map} +0 -0
  181. /package/dist/chunks/{sync-state-VHJUZ4YG.ts.map → setup-switch-KTUWL6DE.ts.map} +0 -0
@@ -1,17 +1,25 @@
1
1
  // @generated by scripts/build-runtime.mjs; do not edit.
2
2
  // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
3
- import {
4
- formatRemoteSelectionStatus,
5
- readRemoteSnapshot,
6
- readSnapshotForHead,
7
- requireCompatibleRemoteSelection
8
- } from "./chunk-Q32CQWPM.ts";
9
3
  import {
10
4
  inspectSync
11
- } from "./chunk-DM4X4FIA.ts";
5
+ } from "./chunk-7D6RDCVC.ts";
12
6
  import {
13
- createSyncBackend
14
- } from "./chunk-HGJH56KR.ts";
7
+ applySnapshot,
8
+ countPreservedRemoteFiles,
9
+ createSyncDecision,
10
+ formatDiff,
11
+ formatPullSummary,
12
+ formatPushSummary,
13
+ formatRollbackSummary,
14
+ formatSnapshotOnlyDiff,
15
+ mergeSync,
16
+ publicationCapabilityDescription
17
+ } from "./chunk-2GUXI2XT.ts";
18
+ import {
19
+ readMergeJournal,
20
+ requireNoMergeJournal,
21
+ retireMergeJournal
22
+ } from "./chunk-ORQRHEEO.ts";
15
23
  import {
16
24
  canPullRemoteSessionsOnFirstSync,
17
25
  canPullRemoteSettingsOnFirstSync,
@@ -24,436 +32,112 @@ import {
24
32
  snapshotHashesMatchState,
25
33
  snapshotsMatch,
26
34
  syncPolicyChanged
27
- } from "./chunk-T56RNRZJ.ts";
35
+ } from "./chunk-BDB4IDM3.ts";
28
36
  import {
29
- applySnapshotTransaction,
30
- recoverPendingSnapshotTransactions
31
- } from "./chunk-4AIHXRKE.ts";
37
+ formatRemoteSelectionStatus,
38
+ readRemoteSnapshot,
39
+ readSnapshotForHead,
40
+ requireCompatibleRemoteSelection
41
+ } from "./chunk-V6VZ5TLG.ts";
32
42
  import {
43
+ createSyncBackend
44
+ } from "./chunk-QHSQCK7L.ts";
45
+ import "./chunk-ANWNQIR7.ts";
46
+ import {
47
+ backupLocal,
48
+ captureMutationOwner,
33
49
  inspectLock,
34
50
  isLockGuardHeld,
35
51
  isStaleLock,
52
+ protectedSessionPaths,
36
53
  withLock
37
- } from "./chunk-QLSN7AGX.ts";
54
+ } from "./chunk-IJ6VV6FN.ts";
55
+ import {
56
+ throwIfAborted
57
+ } from "./chunk-PWMWOJP4.ts";
38
58
  import {
39
59
  setSyncStatus
40
60
  } from "./chunk-K3RIEL6J.ts";
41
61
  import {
42
- throwIfAborted
43
- } from "./chunk-PWMWOJP4.ts";
62
+ safeTerminalText
63
+ } from "./chunk-AMJRUXFT.ts";
64
+ import {
65
+ pruneMergeBaselines
66
+ } from "./chunk-QE7NZZWT.ts";
67
+ import "./chunk-76NB4R7U.ts";
44
68
  import {
45
69
  readStateForConfig,
46
70
  writeStateForConfig
47
- } from "./chunk-YPXGU5OR.ts";
48
- import {
49
- safeTerminalText
50
- } from "./chunk-MSSW6F3R.ts";
71
+ } from "./chunk-4QVPTIZB.ts";
72
+ import "./chunk-BVVP33U5.ts";
51
73
  import {
52
74
  loadConfig,
53
- syncConfigReviewIdentity,
75
+ syncCheckConfigFingerprint,
54
76
  syncSessionsWarnings
55
- } from "./chunk-4MPIH46N.ts";
77
+ } from "./chunk-3B4Z2SKV.ts";
56
78
  import "./chunk-2JZFHNLM.ts";
57
- import {
58
- encodeSnapshot
59
- } from "./chunk-6NT3YFGY.ts";
60
- import {
61
- stateDir
62
- } from "./chunk-BVVP33U5.ts";
63
79
  import "./chunk-T2SJD3N7.ts";
64
80
  import {
65
81
  expectedRemoteHead
66
82
  } from "./chunk-UETBKTDI.ts";
83
+ import "./chunk-ZX2Q6THB.ts";
67
84
  import {
68
- SyncDecisionRequiredError,
69
85
  errorMessage
70
86
  } from "./chunk-7D5SF7P3.ts";
87
+ import {
88
+ overlayLocalFields,
89
+ portableSnapshot,
90
+ sameLocalFields,
91
+ validatePortableSnapshot
92
+ } from "./chunk-A6HU4ERM.ts";
71
93
  import {
72
94
  createSnapshot,
73
95
  filterSnapshotForConfigPolicy,
74
- isSessionFilePath,
75
- isSessionPath,
96
+ inspectRemoteSelection,
76
97
  mergeRemotePreservedFiles,
77
98
  regenerateSnapshotIdentity,
78
99
  scanSnapshot,
79
- sessionSnapshotPathFromAbsolute,
100
+ sessionDirForApply,
80
101
  snapshotIncludesSessions,
81
- snapshotTarget,
102
+ snapshotOptionsForContext,
82
103
  snapshotWithoutSessions
83
- } from "./chunk-EEJPYTWE.ts";
84
- import {
85
- sessionStorageRoot
86
- } from "./chunk-SWUXA3MH.ts";
87
- import {
88
- agentDir,
89
- sessionDirForApply,
90
- sessionDirFromContext,
91
- snapshotOptionsForContext
92
- } from "./chunk-OVDTF5CH.ts";
93
- import {
94
- assertWithinRoot,
95
- includeFromSelectionConfig,
96
- inspectRemoteSelection,
97
- isDeniedPath,
98
- isPathInside,
99
- parentPaths,
100
- safeJoin,
101
- toPosix
102
- } from "./chunk-IVXEDPAD.ts";
103
-
104
- // src/sync/sync-mutations.ts
105
- import fs2 from "node:fs/promises";
106
- import path2 from "node:path";
104
+ } from "./chunk-QOFMXPM5.ts";
105
+ import "./chunk-ZW5ZLGBI.ts";
106
+ import "./chunk-HUZ5CR4W.ts";
107
107
 
108
- // src/snapshot/snapshot-apply.ts
109
- import { createHash } from "node:crypto";
110
- import fs from "node:fs/promises";
111
- import path from "node:path";
112
- function sha256(value) {
113
- return createHash("sha256").update(value).digest("hex");
114
- }
115
- function fileHashMap2(snapshot) {
116
- return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
117
- }
118
- async function applySnapshot(snapshot, protectedRelativePaths = /* @__PURE__ */ new Set(), options = {}) {
119
- const root = agentDir();
120
- const { sessionDir } = options;
121
- await recoverPendingSnapshotTransactions();
122
- const current = await createSnapshot(snapshot.profile, {
123
- ...options,
124
- ...options.include === void 0 ? { syncSessions: snapshotIncludesSessions(snapshot) } : {},
125
- sessionDir
126
- });
127
- const plan = await addTopLevelCaseVariantDeletes(
128
- root,
129
- protectSnapshotApplyPlan(
130
- root,
131
- preflightSnapshotApply(root, snapshot, current, { sessionDir }),
132
- protectedRelativePaths,
133
- sessionDir
134
- ),
135
- snapshot
136
- );
137
- await preflightSnapshotMutations(root, plan, sessionDir);
138
- await applySnapshotTransaction(plan, { sessionDir });
139
- return appliedFileHashMap(snapshot, current, protectedRelativePaths);
140
- }
141
- function preflightSnapshotApply(root, snapshot, current, options = {}) {
142
- const seenPaths = /* @__PURE__ */ new Set();
143
- const remotePaths = /* @__PURE__ */ new Set();
144
- const writes = [];
145
- const deletes = [];
146
- for (const file of snapshot.files) {
147
- const normalized = toPosix(file.path);
148
- if (!isSafeSnapshotPath(file.path)) {
149
- throw new Error(`Unsafe path in snapshot: ${file.path}`);
150
- }
151
- if (isSessionPath(normalized) && !isSessionFilePath(normalized)) {
152
- throw new Error(`Unsafe session path in snapshot: ${file.path}`);
153
- }
154
- if (seenPaths.has(normalized)) throw new Error(`Duplicate path in snapshot: ${normalized}`);
155
- seenPaths.add(normalized);
156
- remotePaths.add(normalized);
157
- const target = snapshotTarget(root, normalized, options.sessionDir);
158
- const content = decodeBase64Strict(file.contentBase64, normalized);
159
- if (sha256(content) !== file.sha256) throw new Error(`Checksum mismatch in snapshot file: ${normalized}`);
160
- writes.push({ target, content });
161
- }
162
- const deletePaths = /* @__PURE__ */ new Set();
163
- for (const file of current.files) {
164
- const normalized = toPosix(file.path);
165
- if (!remotePaths.has(normalized)) {
166
- deletePaths.add(snapshotTarget(root, normalized, options.sessionDir));
167
- }
168
- for (const remotePath of parentPaths(normalized)) {
169
- if (remotePaths.has(remotePath)) {
170
- deletePaths.add(snapshotTarget(root, remotePath, options.sessionDir));
171
- }
172
- }
173
- }
174
- deletes.push(...deletePaths);
175
- return { writes, deletes };
176
- }
177
- function protectSnapshotApplyPlan(root, plan, protectedRelativePaths, sessionDir) {
178
- if (protectedRelativePaths.size === 0) return plan;
179
- const protectedTargets = new Set(
180
- [...protectedRelativePaths].map((relativePath) => snapshotTarget(root, relativePath, sessionDir))
108
+ // src/sync/field-migration.ts
109
+ async function confirmFieldMigration(ctx, config, state, remote, force, signal) {
110
+ if (remote?.localFields !== void 0 && config.localFields === void 0)
111
+ throw new Error(
112
+ "Portable snapshots require settings version 4 and explicit localFields rules on this machine; older policy must not replace local-only values."
113
+ );
114
+ const changed = Boolean(state.lastAppliedSnapshot) && !sameLocalFields(state.localFields, config.localFields) || remote !== void 0 && !sameLocalFields(remote.localFields, config.localFields);
115
+ if (!changed) return true;
116
+ if (!force)
117
+ throw new Error(
118
+ "Local-field policy changed; review an explicit push --force or pull --force migration. Old remote history is not erased."
119
+ );
120
+ const validate = captureMutationOwner(ctx, signal);
121
+ if (!ctx.hasUI) throw new Error("Local-field migration requires observable TUI/RPC confirmation.");
122
+ validate();
123
+ const confirmed = await ctx.ui.confirm(
124
+ "Migrate portable settings policy?",
125
+ `New machine-local fields: ${(config.localFields ?? []).map(safeTerminalText).join(", ") || "none"}. Additions remove fields from future snapshots, not old history. Removals make local-only values portable again and a pull may replace them. The selected direction is authoritative; inspect /sync diff first. This confirmation is required even with --yes.`,
126
+ { signal }
181
127
  );
182
- return {
183
- writes: plan.writes.filter((item) => !protectedTargets.has(item.target)),
184
- deletes: plan.deletes.filter((target) => !protectedTargets.has(target))
185
- };
186
- }
187
- async function addTopLevelCaseVariantDeletes(root, plan, snapshot) {
188
- const topLevelPaths = /* @__PURE__ */ new Map();
189
- for (const file of snapshot.files) {
190
- const normalized = toPosix(file.path);
191
- if (!normalized.includes("/") && isSafeSnapshotPath(file.path)) {
192
- topLevelPaths.set(normalized.toLowerCase(), normalized);
193
- }
194
- }
195
- if (topLevelPaths.size === 0) return plan;
196
- let entries;
197
- try {
198
- entries = await fs.readdir(root, { withFileTypes: true });
199
- } catch (error) {
200
- if (error.code === "ENOENT") return plan;
201
- throw error;
202
- }
203
- const deletes = new Set(plan.deletes);
204
- for (const entry of entries) {
205
- const canonicalPath = topLevelPaths.get(entry.name.toLowerCase());
206
- if (canonicalPath && entry.name !== canonicalPath && (entry.isFile() || entry.isSymbolicLink())) {
207
- deletes.add(safeJoin(root, entry.name));
208
- }
209
- }
210
- return { ...plan, deletes: [...deletes] };
211
- }
212
- function appliedFileHashMap(snapshot, current, protectedRelativePaths) {
213
- const hashes = fileHashMap2(snapshot);
214
- if (protectedRelativePaths.size === 0) return hashes;
215
- const currentHashes = fileHashMap2(current);
216
- for (const relativePath of protectedRelativePaths) {
217
- const normalized = toPosix(relativePath);
218
- if (currentHashes[normalized]) {
219
- hashes[normalized] = currentHashes[normalized];
220
- } else {
221
- delete hashes[normalized];
222
- }
223
- }
224
- return hashes;
225
- }
226
- function isSafeSnapshotPath(relativePath) {
227
- if (relativePath.includes("\\")) return false;
228
- const normalized = toPosix(relativePath);
229
- return Boolean(normalized) && normalized !== "." && normalized !== ".." && !normalized.startsWith("../") && !path.posix.isAbsolute(normalized) && path.posix.normalize(normalized) === normalized && !isDeniedPath(normalized);
230
- }
231
- function decodeBase64Strict(value, filePath) {
232
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
233
- throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
234
- }
235
- return Buffer.from(value, "base64");
236
- }
237
- async function preflightSnapshotMutations(root, plan, sessionDir) {
238
- const deletePaths = new Set(plan.deletes);
239
- for (const target of plan.deletes) {
240
- await assertNoSymlinkParents(rootForTarget(root, target, sessionDir), target);
241
- }
242
- for (const item of plan.writes) {
243
- await prepareSnapshotWrite(rootForTarget(root, item.target, sessionDir), item.target, deletePaths);
244
- }
245
- }
246
- function rootForTarget(root, target, sessionDir) {
247
- const sessionRoot = sessionDir ? sessionStorageRoot(root, sessionDir) : void 0;
248
- if (sessionRoot && isPathInside(sessionRoot, target)) return sessionRoot;
249
- return root;
250
- }
251
- async function prepareSnapshotWrite(root, target, deletePaths) {
252
- const parentWillBeReplaced = await ensureSafeDirectory(root, path.dirname(target), deletePaths);
253
- if (parentWillBeReplaced) return;
254
- try {
255
- const stat = await fs.lstat(target);
256
- if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlink during snapshot apply: ${target}`);
257
- if (stat.isDirectory() && !deletePaths.has(target)) {
258
- throw new Error(`Refusing to overwrite directory during snapshot apply: ${target}`);
259
- }
260
- } catch (error) {
261
- if (error.code !== "ENOENT") throw error;
262
- }
263
- }
264
- async function ensureSafeDirectory(root, directory, deletePaths) {
265
- assertWithinRoot(root, directory);
266
- const rootPath = path.resolve(root);
267
- const relative = path.relative(rootPath, path.resolve(directory));
268
- let current = rootPath;
269
- for (const part of relative.split(path.sep).filter(Boolean)) {
270
- current = path.join(current, part);
271
- try {
272
- const stat = await fs.lstat(current);
273
- if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
274
- if (!stat.isDirectory()) {
275
- if (deletePaths.has(current)) return true;
276
- throw new Error(`Snapshot path parent is not a directory: ${current}`);
277
- }
278
- } catch (error) {
279
- if (error.code !== "ENOENT") throw error;
280
- await fs.mkdir(current);
281
- }
282
- }
283
- return false;
284
- }
285
- async function assertNoSymlinkParents(root, target) {
286
- assertWithinRoot(root, target);
287
- const rootPath = path.resolve(root);
288
- const relative = path.relative(rootPath, path.resolve(target));
289
- let current = rootPath;
290
- const parts = relative.split(path.sep).filter(Boolean);
291
- for (const part of parts.slice(0, -1)) {
292
- current = path.join(current, part);
293
- try {
294
- const stat = await fs.lstat(current);
295
- if (stat.isSymbolicLink()) throw new Error(`Refusing to follow symlink during snapshot apply: ${current}`);
296
- if (!stat.isDirectory()) throw new Error(`Snapshot path parent is not a directory: ${current}`);
297
- } catch (error) {
298
- if (error.code === "ENOENT") return;
299
- throw error;
300
- }
301
- }
302
- }
303
-
304
- // src/ui/sync-format.ts
305
- function formatDiff(local, remote) {
306
- const localMap = fileHashMap(local);
307
- const remoteMap = fileHashMap(remote);
308
- const allPaths = [.../* @__PURE__ */ new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])].sort();
309
- const lines = [`local: ${local.files.length} files`, `remote: ${remote.id} (${remote.files.length} files)`, ""];
310
- let changed = 0;
311
- for (const filePath of allPaths) {
312
- if (!localMap[filePath]) {
313
- lines.push(`Remote only: ${filePath}`);
314
- changed += 1;
315
- } else if (!remoteMap[filePath]) {
316
- lines.push(`Local only: ${filePath}`);
317
- changed += 1;
318
- } else if (localMap[filePath] !== remoteMap[filePath]) {
319
- lines.push(`Different: ${filePath}`);
320
- changed += 1;
321
- }
322
- }
323
- if (changed === 0) lines.push("No file differences.");
324
- return lines.join("\n");
325
- }
326
- function formatSnapshotOnlyDiff(title, snapshot) {
327
- return [`${title}: ${snapshot.id}`, ...snapshot.files.map((file) => `Add: ${file.path}`)].join("\n");
328
- }
329
- function formatPushSummary(config, destination, upload, head, preservedRemoteFileCount = 0, remote) {
330
- return [
331
- `Sync setup: ${safeTerminalText(config.setupName)}`,
332
- `Storage location: ${safeTerminalText(destination)}`,
333
- `Upload ${upload.files.length} files from ${safeTerminalText(agentDir())}.`,
334
- `Sessions: ${upload.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
335
- head ? `Remote latest: ${head.snapshotId}` : "Remote latest: empty",
336
- "Publication effect: the backend's active head will reference the new immutable snapshot.",
337
- ...remote && inspectRemoteSelection(config.include, remote).kind === "different" ? ["Included-content policy effect: replace the differing remote selection with this setup's local selection."] : [],
338
- formatPublicationPreview(remote, upload),
339
- preservedRemoteFileCount > 0 ? `Possible secrets in locally managed files were scanned before this prompt; ${preservedRemoteFileCount} preserved remote file(s) were not rescanned.` : "Possible secrets were scanned before this prompt."
340
- ].join("\n");
341
- }
342
- function formatApplyPreview(local, remote) {
343
- return formatDirectionalChanges(local, remote, {
344
- add: "Add locally",
345
- update: "Update locally",
346
- remove: "Remove locally"
347
- });
348
- }
349
- function formatPullSummary(config, destination, local, remote, protectedSessionCount) {
350
- return [
351
- `Sync setup: ${safeTerminalText(config.setupName)}`,
352
- `Storage location: ${safeTerminalText(destination)}`,
353
- `Snapshot: ${safeTerminalText(remote.id)}`,
354
- `Sessions: ${remote.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
355
- `Protected live sessions: ${protectedSessionCount || "none"}`,
356
- formatApplyPreview(local, remote),
357
- "A local backup is created before these writes/deletes. The remote active snapshot is unchanged."
358
- ].join("\n");
359
- }
360
- function formatRollbackSummary(config, destination, local, remote, requestedSnapshot, protectedSessionCount) {
361
- return [
362
- `Sync setup: ${safeTerminalText(config.setupName)}`,
363
- `Storage location: ${safeTerminalText(destination)}`,
364
- `Snapshot: ${safeTerminalText(requestedSnapshot)}`,
365
- `Sessions: ${remote.syncSessions ? "included \u2014 may contain private conversations" : "not included"}`,
366
- `Protected live sessions: ${protectedSessionCount || "none"}`,
367
- formatApplyPreview(local, remote),
368
- "A local backup is created before applying; the backend's active head will change."
369
- ].join("\n");
370
- }
371
- function formatPublicationPreview(remote, upload) {
372
- if (!remote) {
373
- return ["Remote is empty.", ...upload.files.map((file) => `Add remotely: ${file.path}`)].join("\n");
374
- }
375
- return formatDirectionalChanges(remote, upload, {
376
- add: "Add remotely",
377
- update: "Update remotely",
378
- remove: "Remove remotely"
379
- });
380
- }
381
- function formatDirectionalChanges(before, after, labels) {
382
- const beforeMap = fileHashMap(before);
383
- const afterMap = fileHashMap(after);
384
- const paths = [.../* @__PURE__ */ new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])].sort();
385
- const lines = [];
386
- for (const filePath of paths) {
387
- if (!beforeMap[filePath]) lines.push(`${labels.add}: ${filePath}`);
388
- else if (!afterMap[filePath]) lines.push(`${labels.remove}: ${filePath}`);
389
- else if (beforeMap[filePath] !== afterMap[filePath]) lines.push(`${labels.update}: ${filePath}`);
390
- }
391
- if (lines.length === 0) lines.push("No file changes.");
392
- return lines.join("\n");
393
- }
394
- function countPreservedRemoteFiles(local, upload) {
395
- const localPaths = new Set(local.files.map((file) => file.path));
396
- return upload.files.filter((file) => !localPaths.has(file.path)).length;
397
- }
398
- function publicationCapabilityDescription(capability) {
399
- switch (capability) {
400
- case "lease-protected":
401
- return "lease-protected (exact expected-ref update)";
402
- case "atomic-conditional":
403
- return "atomic-conditional (verified atomic precondition)";
404
- case "conditional-required":
405
- return "conditional-required (read-only until atomic preconditions are verified)";
406
- case "read-check-write-verify":
407
- return "read-check-write-verify (visible races rejected; simultaneous unseen races remain possible)";
408
- }
409
- }
410
-
411
- // src/sync/sync-decision.ts
412
- function createSyncDecision(options) {
413
- const { config, state, local, remote, kind } = options;
414
- const policyChanged = Boolean(state.lastAppliedSnapshot) && syncPolicyChanged(state, config);
415
- const previousInclude = includeFromSelectionConfig(state);
416
- const currentInclude = [...config.include];
417
- const causes = {
418
- localChanged: options.localChanged,
419
- remoteChanged: options.remoteChanged,
420
- policyChanged
421
- };
422
- const causeLines = kind === "first-sync-settings-diverged" ? ["This machine and the remote have different Pi settings on first sync."] : kind === "first-sync-sessions-diverged" ? ["Pi settings match, but local and remote sessions differ on first sync."] : kind === "remote-empty" ? ["The remote storage location is empty."] : [
423
- ...causes.localChanged ? ["Local content changed since the last sync."] : [],
424
- ...causes.remoteChanged ? ["Remote content changed since the last sync."] : [],
425
- ...causes.policyChanged ? ["Included content changed since the last sync."] : []
426
- ];
427
- const comparison = remote ? formatDiff(local, remote) : formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local);
428
- const review = [
429
- `Sync setup: ${safeTerminalText(config.setupName)}`,
430
- "",
431
- "Why a decision is required:",
432
- ...causeLines,
433
- ...policyChanged ? ["", `Previously included: ${safeList(previousInclude)}`, `Currently included: ${safeList(currentInclude)}`] : [],
434
- "",
435
- "Observed differences:",
436
- ...comparison.split("\n").map(safeTerminalText)
437
- ].join("\n");
438
- return new SyncDecisionRequiredError({
439
- kind,
440
- setupName: config.setupName,
441
- configIdentity: syncConfigReviewIdentity(config),
442
- causes,
443
- ...policyChanged ? { previousInclude: [...previousInclude] } : {},
444
- currentInclude,
445
- review,
446
- directions: kind === "remote-empty" ? ["push"] : ["push", "pull"],
447
- directMessage: options.directMessage
448
- });
449
- }
450
- function safeList(values) {
451
- return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
128
+ validate();
129
+ return confirmed;
452
130
  }
453
131
 
454
132
  // src/sync/sync-mutations.ts
455
133
  var VERSION = 1;
456
134
  var POST_LOCAL_COMMIT_TIMEOUT_MS = 3e4;
135
+ function requireResolvedDirectionalProgress(state, options, allowForce = true) {
136
+ if (state.unresolved?.length && (options.auto || !options.force || !allowForce))
137
+ throw new Error(
138
+ "Unresolved partial conflicts remain. Review /sync conflicts or explicitly choose push --force or pull --force; automatic transfer and rollback cannot accept unresolved groups."
139
+ );
140
+ }
457
141
  var PublicationStatePersistenceError = class extends Error {
458
142
  head;
459
143
  backupPath;
@@ -478,13 +162,19 @@ var RollbackPublicationError = class extends Error {
478
162
  }
479
163
  };
480
164
  async function push(ctx, options, input, factory = createSyncBackend) {
165
+ const validateMutation = captureMutationOwner(ctx, options.signal);
166
+ const validateCommittedOwner = captureMutationOwner(ctx);
481
167
  const config = input?.config ?? await loadConfig(options.setup);
482
168
  throwIfAborted(options.signal);
169
+ await requireNoMergeJournal(config, options);
170
+ const state = input?.state ?? await readStateForConfig(config);
171
+ validateMutation();
172
+ requireResolvedDirectionalProgress(state, options);
483
173
  setSyncStatus(ctx, `pushing ${config.setupName}`);
484
174
  const backend = input?.backend ?? await factory(config);
485
- const state = input?.state ?? await readStateForConfig(config);
486
175
  throwIfAborted(options.signal);
487
- const local = input?.local ?? await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
176
+ const localRaw = input?.local ?? await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
177
+ const local = portableSnapshot(localRaw, config.localFields);
488
178
  throwIfAborted(options.signal);
489
179
  let head = await backend.readHead(options.signal);
490
180
  let remoteForUpload = await readRemoteSnapshotForUpload(backend, config, head, state, options.signal);
@@ -509,6 +199,13 @@ async function push(ctx, options, input, factory = createSyncBackend) {
509
199
  });
510
200
  }
511
201
  }
202
+ if (!remoteForUpload && head && config.localFields !== void 0)
203
+ remoteForUpload = await readSnapshotForHead(backend, head, options.signal);
204
+ if (!await confirmFieldMigration(ctx, config, state, remoteForUpload, options.force, options.signal)) {
205
+ setSyncStatus(ctx, void 0);
206
+ return "cancelled";
207
+ }
208
+ const configIdentity = syncCheckConfigFingerprint(config);
512
209
  let upload = await snapshotForUpload(backend, config, local, head, remoteForUpload, options.signal);
513
210
  if (!config.skipSecretScan) {
514
211
  const secrets = scanSnapshot(local);
@@ -524,7 +221,13 @@ ${secrets.map((s) => `- ${s}`).join("\n")}`);
524
221
  const refreshedHead = await backend.readHead(options.signal);
525
222
  if (!sameRemoteHead(backend, head, refreshedHead)) {
526
223
  head = refreshedHead;
527
- remoteForUpload = head ? await backend.readSnapshot(head.snapshotRef, options.signal) : void 0;
224
+ remoteForUpload = head ? await readSnapshotForHead(backend, head, options.signal) : void 0;
225
+ validateMutation();
226
+ if (!await confirmFieldMigration(ctx, config, state, remoteForUpload, options.force, options.signal)) {
227
+ setSyncStatus(ctx, void 0);
228
+ return "cancelled";
229
+ }
230
+ validateMutation();
528
231
  upload = await snapshotForUpload(backend, config, local, head, remoteForUpload, options.signal);
529
232
  if (!await confirmPush(
530
233
  ctx,
@@ -541,22 +244,42 @@ ${secrets.map((s) => `- ${s}`).join("\n")}`);
541
244
  }
542
245
  }
543
246
  }
247
+ validateMutation();
248
+ if (configIdentity !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
249
+ throw new Error("Settings changed during push review.");
250
+ validateMutation();
251
+ if (config.localFields !== void 0 || await readMergeJournal(config)) {
252
+ const current = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
253
+ if (!sameHashes(fileHashMap(current), fileHashMap(localRaw)))
254
+ throw new Error("Local content changed during recovery review; retry from current bytes.");
255
+ }
256
+ requireResolvedDirectionalProgress(await readStateForConfig(config), options);
257
+ validateMutation();
544
258
  const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
545
259
  signal: options.signal,
546
260
  onCommit: options.onCommit
547
261
  });
548
262
  try {
549
- await writeStateForConfig(config, {
550
- version: VERSION,
551
- profile: config.snapshotIdentity,
552
- lastAppliedSnapshot: result.head.snapshotId,
553
- lastRemoteRevision: result.head.revision,
554
- lastFileHashes: fileHashMap(local),
555
- include: [...config.include]
556
- });
263
+ await writeStateForConfig(
264
+ config,
265
+ {
266
+ version: VERSION,
267
+ profile: config.snapshotIdentity,
268
+ lastAppliedSnapshot: result.head.snapshotId,
269
+ lastRemoteRevision: result.head.revision,
270
+ lastFileHashes: fileHashMap(local),
271
+ include: [...config.include],
272
+ ...config.localFields !== void 0 ? { localFields: config.localFields } : {}
273
+ },
274
+ local,
275
+ validateCommittedOwner
276
+ );
557
277
  } catch (error) {
558
278
  throw new PublicationStatePersistenceError(result.head, error);
559
279
  }
280
+ await retireMergeJournal(config);
281
+ await pruneMergeBaselines(config, await readStateForConfig(config), validateCommittedOwner);
282
+ validateCommittedOwner();
560
283
  if (options.signal?.aborted) return;
561
284
  setSyncStatus(ctx, void 0);
562
285
  if (!options.silent) {
@@ -571,12 +294,16 @@ ${secrets.map((s) => `- ${s}`).join("\n")}`);
571
294
  return "applied";
572
295
  }
573
296
  async function pull(ctx, options, factory = createSyncBackend) {
297
+ const validateMutation = captureMutationOwner(ctx, options.signal);
298
+ const validateCommittedOwner = captureMutationOwner(ctx);
574
299
  const config = await loadConfig(options.setup);
575
300
  throwIfAborted(options.signal);
301
+ await requireNoMergeJournal(config, options);
576
302
  setSyncStatus(ctx, `pulling ${config.setupName}`);
577
303
  const backend = await factory(config);
578
304
  const state = await readStateForConfig(config);
579
- throwIfAborted(options.signal);
305
+ validateMutation();
306
+ requireResolvedDirectionalProgress(state, options);
580
307
  const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
581
308
  throwIfAborted(options.signal);
582
309
  const { head, snapshot: remote } = await readRemoteSnapshot(backend, config, options.signal);
@@ -593,6 +320,19 @@ async function pull(ctx, options, factory = createSyncBackend) {
593
320
  directMessage: "Remote is empty. Run /sync push from a configured machine first."
594
321
  });
595
322
  }
323
+ if (!sameLocalFields(config.localFields, remote.localFields)) {
324
+ setSyncStatus(ctx, void 0);
325
+ throw new Error(
326
+ "Pull requires the configured local-field policy to match the remote. To change the remote policy, review push --force from the authoritative machine first, then pull using matching rules; old history is not erased."
327
+ );
328
+ }
329
+ if (!await confirmFieldMigration(ctx, config, state, remote, options.force, options.signal)) {
330
+ setSyncStatus(ctx, void 0);
331
+ return "cancelled";
332
+ }
333
+ const configIdentity = syncCheckConfigFingerprint(config);
334
+ const logical = portableSnapshot(remote, config.localFields);
335
+ const physical = overlayLocalFields(logical, local, config.localFields);
596
336
  const remoteChanged = hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx));
597
337
  if (localChanged && remoteChanged && state.lastAppliedSnapshot && !options.force) {
598
338
  throw createSyncDecision({
@@ -608,29 +348,65 @@ async function pull(ctx, options, factory = createSyncBackend) {
608
348
  }
609
349
  if (!options.yes && !await ctx.ui.confirm(
610
350
  snapshotIncludesSessions(remote) ? "Pull pi settings and sessions?" : "Pull pi settings?",
611
- formatPullSummary(config, backend.destination, local, remote, protectedSessionPaths(ctx).size)
351
+ formatPullSummary(config, backend.destination, local, physical, protectedSessionPaths(ctx).size)
612
352
  )) {
613
353
  setSyncStatus(ctx, void 0);
614
354
  ctx.ui.notify("Pull cancelled.", "info");
615
355
  return "cancelled";
616
356
  }
617
357
  throwIfAborted(options.signal);
358
+ const recoveringMerge = Boolean(await readMergeJournal(config));
359
+ const requireFreshRecovery = async () => {
360
+ validateMutation();
361
+ const currentHead = await backend.readHead(options.signal);
362
+ validateMutation();
363
+ const current = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
364
+ validateMutation();
365
+ if (!sameHashes(fileHashMap(current), fileHashMap(local)) || !sameRemoteHead(backend, head, currentHead)) {
366
+ throw new Error("Local or remote content changed during recovery review; retry from a fresh diff.");
367
+ }
368
+ };
369
+ if (recoveringMerge) await requireFreshRecovery();
370
+ if (configIdentity !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
371
+ throw new Error("Settings changed during pull review.");
372
+ validateMutation();
373
+ const refreshed = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
374
+ if (!sameHashes(fileHashMap(local), fileHashMap(refreshed)))
375
+ throw new Error("Local bytes changed during pull review.");
376
+ if (!sameRemoteHead(backend, head, await backend.readHead(options.signal)))
377
+ throw new Error("Remote changed during pull review.");
378
+ validateMutation();
618
379
  const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
619
- const applySessionDir = await sessionDirForApply(ctx, remote);
620
- throwIfAborted(options.signal);
380
+ const applySessionDir = await sessionDirForApply(ctx, physical);
381
+ if (recoveringMerge) await requireFreshRecovery();
382
+ requireResolvedDirectionalProgress(await readStateForConfig(config), options);
383
+ validateMutation();
621
384
  options.onCommit?.();
622
- const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
385
+ const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
623
386
  include: config.include,
624
- sessionDir: applySessionDir
625
- });
626
- await writeStateForConfig(config, {
627
- version: VERSION,
628
- profile: config.snapshotIdentity,
629
- lastAppliedSnapshot: remote.id,
630
- lastRemoteRevision: head?.revision,
631
- lastFileHashes,
632
- include: [...config.include]
387
+ sessionDir: applySessionDir,
388
+ signal: options.signal,
389
+ validateMutation,
390
+ expectedFileHashes: config.localFields?.length ? { "settings.json": fileHashMap(local)["settings.json"] ?? null } : void 0
633
391
  });
392
+ validateCommittedOwner();
393
+ await writeStateForConfig(
394
+ config,
395
+ {
396
+ version: VERSION,
397
+ profile: config.snapshotIdentity,
398
+ lastAppliedSnapshot: remote.id,
399
+ lastRemoteRevision: head?.revision,
400
+ lastFileHashes: config.localFields?.length ? { ...lastFileHashes, "settings.json": fileHashMap(logical)["settings.json"] } : lastFileHashes,
401
+ include: [...config.include],
402
+ ...config.localFields !== void 0 ? { localFields: config.localFields } : {}
403
+ },
404
+ logical,
405
+ validateCommittedOwner
406
+ );
407
+ await retireMergeJournal(config);
408
+ await pruneMergeBaselines(config, await readStateForConfig(config), validateCommittedOwner);
409
+ validateCommittedOwner();
634
410
  if (options.signal?.aborted) return "applied";
635
411
  setSyncStatus(ctx, void 0);
636
412
  if (!options.silent) {
@@ -641,15 +417,20 @@ async function pull(ctx, options, factory = createSyncBackend) {
641
417
  "warning"
642
418
  );
643
419
  }
644
- if (options.reload) await maybeReload(ctx, options.signal);
420
+ if (options.reload) await maybeReload(ctx, options.signal, validateMutation);
645
421
  return "applied";
646
422
  }
647
423
  async function syncBoth(ctx, options, factory = createSyncBackend) {
424
+ const validateMutation = captureMutationOwner(ctx, options.signal);
648
425
  const config = await loadConfig(options.setup);
649
426
  throwIfAborted(options.signal);
650
- const backend = await factory(config);
427
+ if (config.include.length > 0 && options.auto && config.automaticTransfer) return mergeSync(ctx, options, factory);
428
+ if (!options.auto && config.include.length > 0 && await readMergeJournal(config))
429
+ return mergeSync(ctx, options, factory);
651
430
  const state = await readStateForConfig(config);
652
431
  throwIfAborted(options.signal);
432
+ if (!options.auto && config.include.length > 0 && state.lastAppliedSnapshot) return mergeSync(ctx, options, factory);
433
+ const backend = await factory(config);
653
434
  const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
654
435
  throwIfAborted(options.signal);
655
436
  if (config.include.length === 0) {
@@ -666,8 +447,34 @@ async function syncBoth(ctx, options, factory = createSyncBackend) {
666
447
  const localChanged = hasLocalChanges(local, state, config);
667
448
  const remoteChanged = remote ? hasRemoteChanges(remote, state, config, protectedSessionPaths(ctx)) : false;
668
449
  const firstSync = !state.lastAppliedSnapshot;
450
+ if (options.auto) throw new Error("Automatic transfer is not authorized by the current settings.");
451
+ const portableLocal = portableSnapshot(local, config.localFields);
452
+ const acceptMatchingState = async (snapshot) => {
453
+ requireCompatibleRemoteSelection(config, snapshot);
454
+ const accepted = portableSnapshot(snapshot, config.localFields);
455
+ if (state.lastAppliedSnapshot && !sameLocalFields(state.localFields, config.localFields))
456
+ throw new Error("Local-field rules changed; review and confirm a directional migration first.");
457
+ await writeStateForConfig(
458
+ config,
459
+ {
460
+ version: VERSION,
461
+ profile: config.snapshotIdentity,
462
+ lastAppliedSnapshot: snapshot.id,
463
+ lastRemoteRevision: head?.revision,
464
+ lastFileHashes: fileHashMap(accepted),
465
+ include: [...config.include],
466
+ ...config.localFields !== void 0 ? { localFields: config.localFields } : {}
467
+ },
468
+ accepted,
469
+ validateMutation
470
+ );
471
+ await pruneMergeBaselines(config, await readStateForConfig(config), validateMutation);
472
+ validateMutation();
473
+ };
669
474
  if (firstSync && remote && remote.files.length > 0 && local.files.length > 0) {
670
- if (!canPullRemoteSettingsOnFirstSync(local, remote)) {
475
+ requireCompatibleRemoteSelection(config, remote);
476
+ const portableRemote = portableSnapshot(remote, config.localFields);
477
+ if (!canPullRemoteSettingsOnFirstSync(portableLocal, portableRemote)) {
671
478
  throw createSyncDecision({
672
479
  kind: "first-sync-settings-diverged",
673
480
  config,
@@ -679,8 +486,8 @@ async function syncBoth(ctx, options, factory = createSyncBackend) {
679
486
  directMessage: "Remote settings exist and this machine has different local Pi settings. Run /sync diff, then manually choose /sync pull or /sync push."
680
487
  });
681
488
  }
682
- if (!sameHashes(fileHashMap(local), fileHashMap(remote))) {
683
- if (!canPullRemoteSessionsOnFirstSync(local, remote)) {
489
+ if (!sameHashes(fileHashMap(portableLocal), fileHashMap(portableRemote))) {
490
+ if (!canPullRemoteSessionsOnFirstSync(portableLocal, portableRemote)) {
684
491
  throw createSyncDecision({
685
492
  kind: "first-sync-sessions-diverged",
686
493
  config,
@@ -695,26 +502,13 @@ async function syncBoth(ctx, options, factory = createSyncBackend) {
695
502
  await pull(ctx, options, factory);
696
503
  return;
697
504
  }
698
- await writeStateForConfig(config, {
699
- version: VERSION,
700
- profile: config.snapshotIdentity,
701
- lastAppliedSnapshot: remote.id,
702
- lastRemoteRevision: head?.revision,
703
- lastFileHashes: fileHashMap(remote),
704
- include: [...config.include]
705
- });
505
+ await acceptMatchingState(remote);
706
506
  if (!options.silent) ctx.ui.notify("pi-sync state initialized; local settings already match remote.", "info");
707
507
  return;
708
508
  }
709
- if (localChanged && remoteChanged && remote && snapshotsMatch(local, remote)) {
710
- await writeStateForConfig(config, {
711
- version: VERSION,
712
- profile: config.snapshotIdentity,
713
- lastAppliedSnapshot: remote.id,
714
- lastRemoteRevision: head?.revision,
715
- lastFileHashes: fileHashMap(remote),
716
- include: [...config.include]
717
- });
509
+ if (localChanged && remoteChanged && remote && snapshotsMatch(portableLocal, portableSnapshot(remote, remote.localFields))) {
510
+ requireCompatibleRemoteSelection(config, remote);
511
+ await acceptMatchingState(remote);
718
512
  if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
719
513
  return;
720
514
  }
@@ -739,18 +533,13 @@ async function syncBoth(ctx, options, factory = createSyncBackend) {
739
533
  return;
740
534
  }
741
535
  if (shouldRefreshSyncedState(remote, head, state, config, (left, right) => backend.sameRevision(left, right))) {
742
- await writeStateForConfig(config, {
743
- version: VERSION,
744
- profile: config.snapshotIdentity,
745
- lastAppliedSnapshot: remote.id,
746
- lastRemoteRevision: head?.revision,
747
- lastFileHashes: fileHashMap(remote),
748
- include: [...config.include]
749
- });
536
+ await acceptMatchingState(remote);
750
537
  }
751
538
  if (!options.silent) ctx.ui.notify("pi-sync is already up to date.", "info");
752
539
  }
753
540
  async function rollback(ctx, options, factory = createSyncBackend, expectedSelection) {
541
+ const validateMutation = captureMutationOwner(ctx, options.signal);
542
+ const validateCommittedOwner = captureMutationOwner(ctx);
754
543
  const target = options.args[0];
755
544
  if (!target) throw new Error("Usage: /sync rollback <snapshot-id> [--yes]");
756
545
  const config = await loadConfig(options.setup);
@@ -759,37 +548,63 @@ async function rollback(ctx, options, factory = createSyncBackend, expectedSelec
759
548
  if (expectedSelection && (backend.identity !== expectedSelection.backendIdentity || config.setupName !== expectedSelection.setup)) {
760
549
  throw new Error("Sync setup or storage location changed while history was open; reopen history and retry.");
761
550
  }
551
+ await requireNoMergeJournal(config);
552
+ const state = await readStateForConfig(config);
553
+ validateMutation();
554
+ requireResolvedDirectionalProgress(state, options, false);
762
555
  const decoded = await backend.readSnapshot(target, options.signal);
556
+ validatePortableSnapshot(decoded);
557
+ if (!sameLocalFields(decoded.localFields, config.localFields))
558
+ throw new Error(
559
+ "Rollback requires the historical snapshot local-field policy to match the configured rules. Review push --force from current local bytes to migrate policy first; choose matching history afterward."
560
+ );
763
561
  const selected = filterSnapshotForConfigPolicy(
764
562
  config.include.includes("sessions") ? decoded : snapshotWithoutSessions(decoded),
765
563
  config
766
564
  );
767
- const remote = regenerateSnapshotIdentity(selected);
565
+ const remote = portableSnapshot(regenerateSnapshotIdentity(selected), config.localFields);
768
566
  const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
769
567
  const expectedHead = await backend.readHead(options.signal);
770
- throwIfAborted(options.signal);
568
+ const currentRemote = expectedHead ? await readSnapshotForHead(backend, expectedHead, options.signal) : void 0;
569
+ validateMutation();
570
+ if (!await confirmFieldMigration(ctx, config, state, currentRemote, true, options.signal)) return;
571
+ validateMutation();
572
+ const physical = overlayLocalFields(remote, local, config.localFields);
771
573
  if (!options.yes && !await ctx.ui.confirm(
772
574
  snapshotIncludesSessions(remote) ? "Rollback pi settings and sessions?" : "Rollback pi settings?",
773
- formatRollbackSummary(config, backend.destination, local, remote, target, protectedSessionPaths(ctx).size)
575
+ formatRollbackSummary(config, backend.destination, local, physical, target, protectedSessionPaths(ctx).size)
774
576
  )) {
775
577
  ctx.ui.notify("Rollback cancelled.", "info");
776
578
  return;
777
579
  }
580
+ validateMutation();
581
+ const currentLocal = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
582
+ if (!sameHashes(fileHashMap(currentLocal), fileHashMap(local)))
583
+ throw new Error("Local bytes changed during rollback review.");
584
+ if (syncCheckConfigFingerprint(config) !== syncCheckConfigFingerprint(await loadConfig(options.setup)))
585
+ throw new Error("Settings changed during rollback review.");
586
+ if (!sameRemoteHead(backend, expectedHead, await backend.readHead(options.signal)))
587
+ throw new Error("Remote changed during rollback review.");
588
+ validateMutation();
778
589
  throwIfAborted(options.signal);
779
590
  const backup = await backupLocal(config.snapshotIdentity, snapshotOptionsForContext(ctx, config), options.signal);
780
591
  const applySessionDir = await sessionDirForApply(ctx, remote);
781
- throwIfAborted(options.signal);
592
+ requireResolvedDirectionalProgress(await readStateForConfig(config), options, false);
593
+ validateMutation();
782
594
  options.onCommit?.();
783
- const lastFileHashes = await applySnapshot(remote, protectedSessionPaths(ctx), {
595
+ const lastFileHashes = await applySnapshot(physical, protectedSessionPaths(ctx), {
784
596
  include: config.include,
785
- sessionDir: applySessionDir
597
+ sessionDir: applySessionDir,
598
+ signal: options.signal,
599
+ validateMutation,
600
+ expectedFileHashes: config.localFields?.length ? { "settings.json": fileHashMap(local)["settings.json"] ?? null } : void 0
786
601
  });
602
+ validateCommittedOwner();
787
603
  let result;
788
604
  try {
789
605
  const completionSignal = AbortSignal.timeout(POST_LOCAL_COMMIT_TIMEOUT_MS);
790
- const upload = await snapshotForUpload(backend, config, remote, expectedHead, void 0, completionSignal, {
791
- ignoreUnreadableRemote: true
792
- });
606
+ const upload = await snapshotForUpload(backend, config, remote, expectedHead, currentRemote, completionSignal);
607
+ validateCommittedOwner();
793
608
  result = await backend.publishSnapshot(upload, expectedRemoteHead(expectedHead), {
794
609
  signal: completionSignal
795
610
  });
@@ -797,17 +612,26 @@ async function rollback(ctx, options, factory = createSyncBackend, expectedSelec
797
612
  throw new RollbackPublicationError(backup, error);
798
613
  }
799
614
  try {
800
- await writeStateForConfig(config, {
801
- version: VERSION,
802
- profile: config.snapshotIdentity,
803
- lastAppliedSnapshot: result.head.snapshotId,
804
- lastRemoteRevision: result.head.revision,
805
- lastFileHashes,
806
- include: [...config.include]
807
- });
615
+ await writeStateForConfig(
616
+ config,
617
+ {
618
+ version: VERSION,
619
+ profile: config.snapshotIdentity,
620
+ lastAppliedSnapshot: result.head.snapshotId,
621
+ lastRemoteRevision: result.head.revision,
622
+ lastFileHashes: config.localFields?.length ? { ...lastFileHashes, "settings.json": fileHashMap(remote)["settings.json"] } : lastFileHashes,
623
+ include: [...config.include],
624
+ ...config.localFields !== void 0 ? { localFields: config.localFields } : {}
625
+ },
626
+ remote,
627
+ validateCommittedOwner
628
+ );
808
629
  } catch (error) {
809
630
  throw new PublicationStatePersistenceError(result.head, error, backup);
810
631
  }
632
+ await retireMergeJournal(config);
633
+ await pruneMergeBaselines(config, await readStateForConfig(config), validateCommittedOwner);
634
+ validateCommittedOwner();
811
635
  if (options.signal?.aborted) return;
812
636
  ctx.ui.notify(
813
637
  [
@@ -816,22 +640,17 @@ async function rollback(ctx, options, factory = createSyncBackend, expectedSelec
816
640
  ].filter(Boolean).join("\n"),
817
641
  result.warnings.length > 0 ? "warning" : "info"
818
642
  );
819
- await maybeReload(ctx, options.signal);
820
- }
821
- function protectedSessionPaths(ctx) {
822
- const getSessionFile = ctx.sessionManager.getSessionFile;
823
- if (typeof getSessionFile !== "function") return /* @__PURE__ */ new Set();
824
- const sessionFile = getSessionFile.call(ctx.sessionManager);
825
- const snapshotPath = sessionFile ? sessionSnapshotPathFromAbsolute(sessionFile, sessionDirFromContext(ctx)) : void 0;
826
- return snapshotPath ? /* @__PURE__ */ new Set([snapshotPath]) : /* @__PURE__ */ new Set();
643
+ await maybeReload(ctx, options.signal, validateMutation);
827
644
  }
828
- async function maybeReload(ctx, signal) {
645
+ async function maybeReload(ctx, signal, validateMutation = captureMutationOwner(ctx, signal)) {
829
646
  if (signal?.aborted || !("reload" in ctx)) return;
647
+ validateMutation();
830
648
  if (ctx.hasUI && await ctx.ui.confirm(
831
649
  "Reload Pi resources now?",
832
650
  "This reloads extensions, skills, prompts, themes, and context files."
833
651
  )) {
834
652
  if (signal?.aborted) return;
653
+ validateMutation();
835
654
  await ctx.reload();
836
655
  }
837
656
  }
@@ -839,19 +658,11 @@ async function readRemoteSnapshotForUpload(backend, config, head, state, signal)
839
658
  if (!head || head.snapshotId === state.lastAppliedSnapshot && !syncPolicyChanged(state, config) && (!state.lastRemoteRevision || backend.sameRevision(head.revision, state.lastRemoteRevision))) {
840
659
  return void 0;
841
660
  }
842
- return backend.readSnapshot(head.snapshotRef, signal);
661
+ return readSnapshotForHead(backend, head, signal);
843
662
  }
844
- async function snapshotForUpload(backend, config, local, head, remote, signal, options = {}) {
663
+ async function snapshotForUpload(backend, config, local, head, remote, signal) {
845
664
  if (!head) return local;
846
- let snapshot = remote;
847
- if (!snapshot) {
848
- try {
849
- snapshot = await backend.readSnapshot(head.snapshotRef, signal);
850
- } catch (error) {
851
- if (options.ignoreUnreadableRemote) return local;
852
- throw error;
853
- }
854
- }
665
+ const snapshot = remote ?? await readSnapshotForHead(backend, head, signal);
855
666
  return mergeRemotePreservedFiles(local, snapshot, config);
856
667
  }
857
668
  async function confirmPush(ctx, options, config, backend, local, upload, head, remote, title = snapshotIncludesSessions(upload) ? "Push pi settings and sessions?" : "Push pi settings?") {
@@ -871,19 +682,6 @@ function sameRemoteHead(backend, left, right) {
871
682
  if (!left || !right) return left === right;
872
683
  return backend.sameRevision(left.revision, right.revision);
873
684
  }
874
- async function backupLocal(profile, options = {}, signal) {
875
- throwIfAborted(signal);
876
- const snapshot = await createSnapshot(profile, options);
877
- throwIfAborted(signal);
878
- const backupDirectory = path2.join(stateDir(), "backups");
879
- await fs2.mkdir(backupDirectory, { recursive: true });
880
- throwIfAborted(signal);
881
- const backupPath = path2.join(backupDirectory, `${snapshot.id}.json.gz`);
882
- const encoded = await encodeSnapshot(snapshot);
883
- throwIfAborted(signal);
884
- await fs2.writeFile(backupPath, encoded, { signal });
885
- return backupPath;
886
- }
887
685
 
888
686
  // src/sync/sync-queries.ts
889
687
  var DEFAULT_PROFILE = "default";
@@ -924,7 +722,10 @@ async function diff(ctx, options, factory = createSyncBackend) {
924
722
  throwIfAborted(options.signal);
925
723
  setSyncStatus(ctx, `checking ${config.setupName}`);
926
724
  const backend = await factory(config);
927
- const local = await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config));
725
+ const local = portableSnapshot(
726
+ await createSnapshot(config.snapshotIdentity, snapshotOptionsForContext(ctx, config)),
727
+ config.localFields
728
+ );
928
729
  throwIfAborted(options.signal);
929
730
  const { snapshot: remote, selectionState } = await readRemoteSnapshot(backend, config, options.signal, {
930
731
  allowSelectionDifference: true
@@ -950,7 +751,7 @@ ${formatSnapshotOnlyDiff("Remote is empty. Local push would upload", local)}`, l
950
751
  }
951
752
  ctx.ui.notify(`${header}
952
753
 
953
- ${formatDiff(local, remote)}`, level);
754
+ ${formatDiff(local, portableSnapshot(remote, remote.localFields))}`, level);
954
755
  }
955
756
  async function doctor(ctx, options, factory = createSyncBackend) {
956
757
  const messages = [];
@@ -1075,4 +876,4 @@ export {
1075
876
  status,
1076
877
  syncBoth
1077
878
  };
1078
- //# sourceMappingURL=sync-operations-AR76EB6C.ts.map
879
+ //# sourceMappingURL=sync-operations-27VQUDH2.ts.map