@edspencer/paddock 0.71.2 → 0.72.1

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 (106) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.js +27 -0
  3. package/packages/server/dist/cli/args.d.ts +56 -5
  4. package/packages/server/dist/cli/args.js +193 -31
  5. package/packages/server/dist/cli/config.d.ts +157 -0
  6. package/packages/server/dist/cli/config.js +462 -0
  7. package/packages/server/dist/cli/paddock.js +53 -3
  8. package/packages/server/dist/config.d.ts +36 -1
  9. package/packages/server/dist/config.js +59 -44
  10. package/packages/server/dist/herdctl.d.ts +45 -0
  11. package/packages/server/dist/herdctl.js +58 -4
  12. package/packages/server/dist/instance-config.d.ts +149 -21
  13. package/packages/server/dist/instance-config.js +228 -5
  14. package/packages/server/dist/last-activity.d.ts +21 -0
  15. package/packages/server/dist/last-activity.js +7 -2
  16. package/packages/server/dist/profiles.d.ts +154 -0
  17. package/packages/server/dist/profiles.js +214 -0
  18. package/packages/server/dist/routes/transcripts.d.ts +61 -0
  19. package/packages/server/dist/routes/transcripts.js +676 -0
  20. package/packages/server/dist/routes.d.ts +3 -0
  21. package/packages/server/dist/routes.js +2 -0
  22. package/packages/server/dist/transcript-move.d.ts +62 -0
  23. package/packages/server/dist/transcript-move.js +179 -0
  24. package/packages/server/dist/transcripts-migration-execute.d.ts +144 -0
  25. package/packages/server/dist/transcripts-migration-execute.js +630 -0
  26. package/packages/server/dist/transcripts-migration.d.ts +336 -0
  27. package/packages/server/dist/transcripts-migration.js +917 -0
  28. package/packages/server/dist/transcripts.d.ts +46 -2
  29. package/packages/server/dist/transcripts.js +126 -3
  30. package/packages/server/package.json +1 -1
  31. package/packages/web/dist/assets/DiscoverPage-DhrXvgkl.js +2 -0
  32. package/packages/web/dist/assets/DiscoverView-yPLCp_YE.js +2 -0
  33. package/packages/web/dist/assets/InstanceConfigPage-aOgiBFLq.js +2 -0
  34. package/packages/web/dist/assets/{MarkdownRenderer-DA5CpdGI.js → MarkdownRenderer-BjrB0TDO.js} +1 -1
  35. package/packages/web/dist/assets/ProjectMenu-PhwXTmgn.js +2 -0
  36. package/packages/web/dist/assets/{ProjectRedirect-k10uM_63.js → ProjectRedirect-N4g7WrZm.js} +1 -1
  37. package/packages/web/dist/assets/ProjectView-B-7AIt--.js +13 -0
  38. package/packages/web/dist/assets/{ProjectsGrid-DrzZ7R8M.js → ProjectsGrid-DZF5VHJF.js} +1 -1
  39. package/packages/web/dist/assets/RootHome-C9wEtWHu.js +2 -0
  40. package/packages/web/dist/assets/Surfaces-BidCkENw.js +2 -0
  41. package/packages/web/dist/assets/{arc-BKAVFR26.js → arc-BctfAqLY.js} +1 -1
  42. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-BJgwSqOO.js +37 -0
  43. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Eu4BV7Ea.js → blockDiagram-GPEHLZMM-BD2skmST.js} +1 -1
  44. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DR8PZaoa.js → c4Diagram-AAUBKEIU-DckMrhMv.js} +1 -1
  45. package/packages/web/dist/assets/channel-CsZOz8jm.js +2 -0
  46. package/packages/web/dist/assets/{chunk-2J33WTMH-CgvIIrI1.js → chunk-2J33WTMH-CQDUH7rp.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-4BX2VUAB-CyJWrZqN.js → chunk-4BX2VUAB-Bae1J961.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-55IACEB6-DXEnZN1d.js → chunk-55IACEB6-CXxS5wOa.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-727SXJPM-Co89QTmp.js → chunk-727SXJPM-DSNFkPB3.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BRxTzfev.js → chunk-AQP2D5EJ-B2mqFyB7.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-FMBD7UC4-DUj18CvF.js → chunk-FMBD7UC4-BfqEy3lu.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-ND2GUHAM-BQMMJobN.js → chunk-ND2GUHAM-Bm6CBVK7.js} +1 -1
  53. package/packages/web/dist/assets/{chunk-QZHKN3VN-COx8LZCe.js → chunk-QZHKN3VN-NmnLtgYc.js} +1 -1
  54. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DdALkhE9.js +2 -0
  55. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DdALkhE9.js +2 -0
  56. package/packages/web/dist/assets/{core-DnpcT0s6.js → core-YKeFSK1q.js} +1 -1
  57. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-qf_mXpa9.js +2 -0
  58. package/packages/web/dist/assets/{dagre-BM42HDAG-CuaYJexd.js → dagre-BM42HDAG-C31v-j3s.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-2AECGRRQ-C9_anxrA.js → diagram-2AECGRRQ-C_TiPHdS.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-5GNKFQAL-Ckf3kFlY.js → diagram-5GNKFQAL-BC8gUFK-.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-KO2AKTUF-BrpUuY5x.js → diagram-KO2AKTUF-vO_QYWxi.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-LMA3HP47-B9lyXZXW.js → diagram-LMA3HP47-vxJ-YZOg.js} +1 -1
  63. package/packages/web/dist/assets/{diagram-OG6HWLK6-DT7ro32m.js → diagram-OG6HWLK6-BNIfJG6l.js} +1 -1
  64. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ZZn9bHQh.js → erDiagram-TEJ5UH35-B37HsOFM.js} +1 -1
  65. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-BxQ82PY_.js → flowDiagram-I6XJVG4X-DqA5X81H.js} +1 -1
  66. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BMP0zpIg.js +293 -0
  67. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BlwODdH9.js → gitGraphDiagram-PVQCEYII-P0nXaCzx.js} +1 -1
  68. package/packages/web/dist/assets/index-BkY_Nc_i.css +1 -0
  69. package/packages/web/dist/assets/index-C3MdluUb.js +71 -0
  70. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-nanSBFyG.js → infoDiagram-5YYISTIA-DpKYQ7VK.js} +1 -1
  71. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-yZHr_s28.js → ishikawaDiagram-YF4QCWOH-BZeRMg3C.js} +1 -1
  72. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BKjDqQEQ.js → journeyDiagram-JHISSGLW-Cg2O0qzv.js} +1 -1
  73. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-QusvHqc6.js → kanban-definition-UN3LZRKU-DBwVxUvI.js} +1 -1
  74. package/packages/web/dist/assets/{linear-4xRoD38Z.js → linear-R8qRwYqx.js} +1 -1
  75. package/packages/web/dist/assets/{mermaid.core-CeulYvDv.js → mermaid.core-jctTs2en.js} +4 -4
  76. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DSjYt2Kp.js → mindmap-definition-RKZ34NQL-CtmsNfpb.js} +1 -1
  77. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Ci0gO3CA.js → pieDiagram-4H26LBE5-C-UiLFDv.js} +1 -1
  78. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-y2qHkaHP.js → quadrantDiagram-W4KKPZXB-BS4SWa01.js} +1 -1
  79. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D9hiWbad.js → requirementDiagram-4Y6WPE33-LPPAC6Q1.js} +1 -1
  80. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DA3Mvoze.js → sankeyDiagram-5OEKKPKP-tjF8KyS3.js} +1 -1
  81. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DWACrljw.js → sequenceDiagram-3UESZ5HK-kmddkdk2.js} +1 -1
  82. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CFGEhoTb.js → stateDiagram-AJRCARHV-B07mykTY.js} +1 -1
  83. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-r6WdX0dw.js +2 -0
  84. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-Cjygpd9e.js → timeline-definition-PNZ67QCA-DrUgi0cT.js} +1 -1
  85. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-zQnLaxgv.js → vennDiagram-CIIHVFJN-Dz6p0m7l.js} +1 -1
  86. package/packages/web/dist/assets/{wardley-L42UT6IY-DGmt-Lo2.js → wardley-L42UT6IY-DM16lA5x.js} +1 -1
  87. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Dj3vDukN.js → wardleyDiagram-YWT4CUSO-CCFQkFGB.js} +1 -1
  88. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-C0bGbwbL.js → xychartDiagram-2RQKCTM6-DiUI5Dos.js} +1 -1
  89. package/packages/web/dist/index.html +2 -2
  90. package/packages/web/dist/sw.js +1 -1
  91. package/packages/web/dist/assets/Chip-Cp32dDmz.js +0 -2
  92. package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +0 -2
  93. package/packages/web/dist/assets/DiscoverView-DoGAlUXT.js +0 -2
  94. package/packages/web/dist/assets/InstanceConfigPage-BDypeo_r.js +0 -2
  95. package/packages/web/dist/assets/ProjectMenu-DXXXtXeh.js +0 -2
  96. package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +0 -13
  97. package/packages/web/dist/assets/RootHome-rWodzOC8.js +0 -2
  98. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-UzIlxaCQ.js +0 -37
  99. package/packages/web/dist/assets/channel-C-sodtn7.js +0 -2
  100. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +0 -2
  101. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +0 -2
  102. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-B-4NadKI.js +0 -2
  103. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BxliloOH.js +0 -293
  104. package/packages/web/dist/assets/index-Cx9y6c3f.js +0 -71
  105. package/packages/web/dist/assets/index-bC5uCujx.css +0 -1
  106. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +0 -2
@@ -0,0 +1,917 @@
1
+ /**
2
+ * The `own → host` transcript migration — the READ half (#882).
3
+ *
4
+ * Flipping `claude.transcripts` from `own` to `host` on an instance that has
5
+ * been used is #708: every pre-flip chat vanishes from the list, files intact
6
+ * but unreachable. This module answers the two questions the guided migration
7
+ * asks BEFORE anything moves:
8
+ *
9
+ * 1. "Is there anything to migrate?" → {@link probeMigration} (the banner)
10
+ * 2. "What exactly would happen?" → {@link buildMigrationPlan} (the table)
11
+ *
12
+ * Nothing here writes, moves or deletes. The execute half — quiesce, move,
13
+ * write `claude.transcripts: host` — is a separate change; see
14
+ * `docs/DESIGN-transcripts-migration.md` §4 for its ordering and commit point.
15
+ *
16
+ * ## The probe asks "is `.chats/` non-empty?", not "is anything new?"
17
+ *
18
+ * The design's first draft short-circuited on the first chat present in
19
+ * `.chats/` and absent from the host store. That is a narrower question than
20
+ * the banner asks, and §10.1 corrects it: a user who adopted their CLI history
21
+ * and then kept working in both places has **zero** such chats — every id
22
+ * exists on both sides — and would never see the banner, despite being exactly
23
+ * the person #882 was opened for.
24
+ *
25
+ * The real predicate comes from the migration's own postcondition (§5): the
26
+ * redirect symlink `pointChatsDirAt` plants on the next boot is declined
27
+ * outright if `.chats/` is a real directory holding **anything**
28
+ * (`transcripts.ts:145`), so every entry must move — including a chat that is
29
+ * byte-identical on both sides. Identity means there is no decision for the
30
+ * user to make; it does not mean there is no work to do. So the probe readdirs
31
+ * `.chats/` and nothing else: one syscall batch per project, no host-store read
32
+ * at all.
33
+ *
34
+ * ## The classifier is staged so cost tracks conflicts, not chat count
35
+ *
36
+ * | Stage | Work | Rules out |
37
+ * |---|---|---|
38
+ * | 0 | `readdir` both sides | an id only in `.chats/` is `new` — no `stat`, no read |
39
+ * | 1 | `stat` both sides | equal size **and** mtime ⇒ identical, no row |
40
+ * | 2 | tail-read the shorter side's last `uuid`, then a **bounded probe** of the longer side in a 32 KB window ending at byte `shorter.size` | a hit ⇒ `fast-forward` |
41
+ * | 3 | full scan of the longer side for that uuid | hit ⇒ `fast-forward` (rewritten history); miss ⇒ `diverged` |
42
+ *
43
+ * Stage 2 is the load-bearing part and the reason the table is affordable at
44
+ * all. A transcript is append-only, so a genuine ancestor's last record *ends
45
+ * at byte offset `shorter.size`* in its descendant — the uuid is at a known
46
+ * offset and does not need to be searched for. Measured 298 hits / 0 misses
47
+ * over 300 real transcripts (design §0.3). The full scan survives only to
48
+ * *confirm divergence*, never to confirm a fast-forward, and it draws from a
49
+ * shared per-request byte budget so a pathological instance degrades to
50
+ * `unknown` rows rather than to an unbounded read. Nothing is silently
51
+ * truncated: `scanBudgetExhausted` and `totals.unknown` are the disclosure.
52
+ */
53
+ import { promises as fs, createReadStream } from "node:fs";
54
+ import path from "node:path";
55
+ import { encodedTranscriptDir, projectChatsDir } from "./transcripts.js";
56
+ import { isConversationRecord } from "./last-activity.js";
57
+ /** The env var that shadows `claude.transcripts` — see `config.ts:1481`. */
58
+ export const TRANSCRIPTS_ENV_VAR = "PADDOCK_CLAUDE_TRANSCRIPTS";
59
+ /**
60
+ * Where unchecked chats are preserved: a SIBLING of `.chats/`, not a child.
61
+ *
62
+ * #882 specified `.chats/pre-migration/`, which cannot work — that directory is
63
+ * "anything", so `pointChatsDirAt` declines the redirect and the migration
64
+ * built to fix #708 ships #708's symptom. Design §5.1 verified it against the
65
+ * real `ensureProjectChats`. Named here because the plan reports the path and
66
+ * the execute half must use the same one.
67
+ */
68
+ export const PRESERVE_DIR_NAME = ".chats-pre-migration";
69
+ /** How much of a transcript's end to read looking for its last record's uuid. */
70
+ const TAIL_WINDOW_BYTES = 32 * 1024;
71
+ /** One wider retry — enough to clear a single enormous tool-result record. */
72
+ const WIDE_TAIL_BYTES = 4 * 1024 * 1024;
73
+ /** The bounded probe's window, ending at byte `shorter.size` in the longer file. */
74
+ const PROBE_WINDOW_BYTES = 32 * 1024;
75
+ /** Chunk size for the stage-3 full scan (raw substring search, no parsing). */
76
+ const SCAN_CHUNK_BYTES = 1024 * 1024;
77
+ /**
78
+ * Bytes stage 3 (and the diverged rows' comparison columns) may read across one
79
+ * whole request. The worst case is a *product* of chat count and transcript
80
+ * size and neither is under our control, so it is bounded rather than trusted.
81
+ */
82
+ export const DIVERGENCE_SCAN_BUDGET_BYTES = 256 * 1024 * 1024;
83
+ /* -------------------------------------------------------------------------- */
84
+ /* directory reading */
85
+ /* -------------------------------------------------------------------------- */
86
+ /** Same guard as `readFirstUserText`: keep a session id inside `.chats/`. */
87
+ const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
88
+ /** A flat sidechain transcript at the top of a store (45 of them on this box). */
89
+ const SIDECHAIN_RE = /^agent-[0-9a-fA-F]+\.jsonl$/;
90
+ function entryKind(name, isDir) {
91
+ if (isDir) {
92
+ if (name === "memory")
93
+ return "memory";
94
+ if (name === ".reverts")
95
+ return "reverts";
96
+ return SESSION_ID_RE.test(name) ? "chat-dir" : "unexpected";
97
+ }
98
+ if (SIDECHAIN_RE.test(name))
99
+ return "sidechain";
100
+ if (name.endsWith(".jsonl") && SESSION_ID_RE.test(name.slice(0, -6)))
101
+ return "chat";
102
+ return "unexpected";
103
+ }
104
+ /**
105
+ * Read one `.chats/`. Returns `null` when there is nothing to migrate from it:
106
+ * missing, or already a symlink (a store that has been pointed at the host,
107
+ * which is what a migrated project looks like before the restart).
108
+ */
109
+ async function readChatsDir(chatsDir) {
110
+ const st = await fs.lstat(chatsDir).catch(() => null);
111
+ if (!st)
112
+ return null;
113
+ if (!st.isDirectory() || st.isSymbolicLink())
114
+ return null;
115
+ let entries;
116
+ try {
117
+ entries = await fs.readdir(chatsDir, { withFileTypes: true });
118
+ }
119
+ catch (err) {
120
+ return { error: err };
121
+ }
122
+ const listing = {
123
+ chats: [],
124
+ chatDirs: new Set(),
125
+ projectExtras: [],
126
+ unexpected: [],
127
+ hasReverts: false,
128
+ hasMemory: false,
129
+ entryCount: entries.length,
130
+ };
131
+ for (const e of entries) {
132
+ const full = path.join(chatsDir, e.name);
133
+ switch (entryKind(e.name, e.isDirectory())) {
134
+ case "chat":
135
+ listing.chats.push(e.name.slice(0, -6));
136
+ break;
137
+ case "chat-dir":
138
+ listing.chatDirs.add(e.name);
139
+ break;
140
+ case "reverts":
141
+ listing.hasReverts = true;
142
+ break;
143
+ case "memory":
144
+ listing.hasMemory = true;
145
+ listing.projectExtras.push(full);
146
+ break;
147
+ case "sidechain":
148
+ listing.projectExtras.push(full);
149
+ break;
150
+ default:
151
+ listing.unexpected.push(full);
152
+ }
153
+ }
154
+ // A `<id>/` with no `<id>.jsonl` has no row to hang off, but the
155
+ // postcondition still moves it — so it is named at project level.
156
+ for (const id of listing.chatDirs) {
157
+ if (!listing.chats.includes(id))
158
+ listing.projectExtras.push(path.join(chatsDir, id));
159
+ }
160
+ return { listing };
161
+ }
162
+ /**
163
+ * Which chat a `.reverts/<id>-<stamp>.jsonl` belongs to.
164
+ *
165
+ * `.reverts/` is shared across sessions (`herdctl.ts:1988`), so a partial
166
+ * migration has to split it — and the id is prefix-matched, longest first, so a
167
+ * chat whose id is a prefix of another chat's cannot steal its reverts.
168
+ */
169
+ function ownerOfRevert(name, chatIds) {
170
+ let best;
171
+ for (let i = name.indexOf("-"); i > 0; i = name.indexOf("-", i + 1)) {
172
+ const candidate = name.slice(0, i);
173
+ if (chatIds.has(candidate) && (best === undefined || candidate.length > best.length)) {
174
+ best = candidate;
175
+ }
176
+ }
177
+ return best;
178
+ }
179
+ /** `mtimeMs:size` of a directory — "has its file *set* changed" (`adoptable.ts:354`). */
180
+ async function dirKey(dir) {
181
+ const st = await fs.stat(dir).catch(() => null);
182
+ return st ? `${st.mtimeMs}:${st.size}` : "-";
183
+ }
184
+ const probeCache = new Map();
185
+ /** Drop the memoised probe answers (tests; a config write). */
186
+ export function resetMigrationProbeCache() {
187
+ probeCache.clear();
188
+ }
189
+ /**
190
+ * "Is there anything to migrate?" — one `readdir` per project, no transcript
191
+ * read and no host-store read at all (§10.1).
192
+ *
193
+ * Memoised per project on `mtimeMs:size` of `.chats/` plus the config version,
194
+ * which costs one `stat` to validate — that is what lets the banner be fetched
195
+ * on every page load. The host-store term the design's §3.4 cache key carried
196
+ * is gone with the host-store read it was there for.
197
+ *
198
+ * ### `pendingChats` is a lower bound that this implementation makes exact
199
+ *
200
+ * The design frames it as a lower bound because the probe "stops counting once
201
+ * the answer is known". `fs.readdir` hands back the whole list in one call, so
202
+ * there is no per-entry short-circuit to be had and counting the `*.jsonl`
203
+ * entries is free — the number is exact. It is still *reported* as a lower
204
+ * bound, because that is the contract clients were given and narrowing it later
205
+ * is a breaking change; but the field is `>= 0` rather than `∈ {0, 1}`.
206
+ *
207
+ * The one wrinkle the corrected predicate introduces: eligibility is about
208
+ * ENTRIES and `pendingChats` counts TRANSCRIPTS, so a `.chats/` holding only
209
+ * `memory/` is `eligible: true` with `pendingChats: 0`. That combination is
210
+ * correct, not a contradiction — there is real work to do and no chat to do it
211
+ * to — and it makes the schema's "exact only when 0" gloss wrong in one
212
+ * direction. Callers must key the banner off `eligible`, never off the count.
213
+ *
214
+ * ### `mode === "host"` is not on its own a reason to refuse
215
+ *
216
+ * The predicate is the same under both modes, which is the #882 ruling (§2)
217
+ * superseding the design's §7.2. `host` shipped as an unconditional refusal on
218
+ * the reasoning "already migrated, nothing to offer", and that is wrong for the
219
+ * one instance that most needs the offer: flipping to `host` while `.chats/`
220
+ * still held anything is #708's STRANDED state. `pointChatsDirAt` declines the
221
+ * redirect against a non-empty real directory (`transcripts.ts:145`), so the old
222
+ * transcripts sit in a real `.chats/` that nothing reads — and the probe told
223
+ * exactly those users there was nothing to do.
224
+ *
225
+ * The distinction is the DIRECTORY, and it is why {@link readChatsDir} `lstat`s:
226
+ *
227
+ * | `.chats/` under `host` | Eligible | Why |
228
+ * |---|---|---|
229
+ * | symlink into the host store | no — `already-host` | migrated; the redirect is planted |
230
+ * | empty real directory | no — `already-host` | the next boot plants it by itself |
231
+ * | non-empty real directory | **yes** | stranded: the redirect was declined |
232
+ *
233
+ * The first two are the overwhelmingly common case and must stay ineligible —
234
+ * showing a migration banner to everyone who has already migrated is worse than
235
+ * the bug this narrows. Under `host` the execute path moves the files and skips
236
+ * the config write (`configWritten: false`, `restartRequired: true`); the next
237
+ * boot then finds an empty `.chats/`, plants the redirect, and the stranded
238
+ * chats reappear. That closes the `own → host` half of #708.
239
+ */
240
+ export async function probeMigration(input) {
241
+ const base = {
242
+ mode: input.mode,
243
+ pendingChats: 0,
244
+ pendingProjects: 0,
245
+ scannedProjects: 0,
246
+ computedAt: new Date().toISOString(),
247
+ };
248
+ // Cheap refusals first, in the order of how much they tell a client. A
249
+ // shadowing env var comes first because it means the flip CANNOT work (the
250
+ // config write would be inert), which a user needs to know even on a posture
251
+ // that hides the banner.
252
+ //
253
+ // `already-host` is NOT among them, and that is the correction — see the
254
+ // `mode === "host"` note below.
255
+ if (input.envShadowed) {
256
+ return {
257
+ ...base,
258
+ eligible: false,
259
+ reason: "env-shadowed",
260
+ envVar: TRANSCRIPTS_ENV_VAR,
261
+ };
262
+ }
263
+ // §10.4: `paranoid` chose isolation deliberately, and a permanent offer to
264
+ // undo the posture you picked is nagging. The migration stays reachable from
265
+ // the Config screen whenever mode is `own` — this hides the banner, not the
266
+ // feature.
267
+ //
268
+ // It does not apply under `host`, where the run changes no posture at all: the
269
+ // instance is already on `host` and the migration only makes its own stranded
270
+ // chats reachable again. Suppressing that would be hiding a recovery, not
271
+ // declining to nag.
272
+ if (input.profile === "paranoid" && input.mode !== "host") {
273
+ return { ...base, eligible: false, reason: "profile-paranoid" };
274
+ }
275
+ let pendingChats = 0;
276
+ let pendingProjects = 0;
277
+ let scanned = 0;
278
+ try {
279
+ for (const project of input.projects) {
280
+ scanned++;
281
+ const chatsDir = projectChatsDir(project.dir);
282
+ const key = `${await dirKey(chatsDir)}|${input.configVersion ?? "-"}`;
283
+ const cached = probeCache.get(project.slug);
284
+ let entry;
285
+ if (cached && cached.key === key) {
286
+ entry = cached;
287
+ }
288
+ else {
289
+ const read = await readChatsDir(chatsDir);
290
+ const listing = read && "listing" in read ? read.listing : null;
291
+ entry = {
292
+ key,
293
+ pending: listing !== null && listing.entryCount > 0,
294
+ chats: listing?.chats.length ?? 0,
295
+ };
296
+ probeCache.set(project.slug, entry);
297
+ }
298
+ if (entry.pending) {
299
+ pendingProjects++;
300
+ pendingChats += entry.chats;
301
+ }
302
+ }
303
+ }
304
+ catch {
305
+ return { ...base, scannedProjects: scanned, eligible: false, reason: "scan-failed" };
306
+ }
307
+ return {
308
+ ...base,
309
+ scannedProjects: scanned,
310
+ pendingChats,
311
+ pendingProjects,
312
+ eligible: pendingProjects > 0,
313
+ // Nothing pending under `host` is the HEALTHY migrated instance, and it
314
+ // keeps the reason it has always had. `nothing-pending` would be true but
315
+ // less useful — it says "no work" where `already-host` says "no work, and
316
+ // here is why there never will be".
317
+ ...(pendingProjects > 0
318
+ ? {}
319
+ : { reason: input.mode === "host" ? "already-host" : "nothing-pending" }),
320
+ };
321
+ }
322
+ /* -------------------------------------------------------------------------- */
323
+ /* transcript reads */
324
+ /* -------------------------------------------------------------------------- */
325
+ /** The last `bytes` of `file` ending at `end`, and whether that reached byte 0. */
326
+ async function readWindow(file, end, bytes) {
327
+ const take = Math.min(bytes, end);
328
+ const start = end - take;
329
+ const handle = await fs.open(file, "r");
330
+ try {
331
+ const buf = Buffer.alloc(take);
332
+ await handle.read(buf, 0, take, start);
333
+ return { text: buf.toString("utf8"), whole: start === 0 };
334
+ }
335
+ finally {
336
+ await handle.close();
337
+ }
338
+ }
339
+ /** The `uuid` of the last record in `text`, walking backwards. */
340
+ function lastUuidIn(text, whole) {
341
+ const lines = text.split("\n");
342
+ // A byte offset almost certainly landed mid-line; that fragment is the OLDEST
343
+ // line in the window, so dropping it costs nothing.
344
+ if (!whole)
345
+ lines.shift();
346
+ for (let i = lines.length - 1; i >= 0; i--) {
347
+ const line = lines[i].trim();
348
+ if (!line)
349
+ continue;
350
+ let rec;
351
+ try {
352
+ rec = JSON.parse(line);
353
+ }
354
+ catch {
355
+ continue;
356
+ }
357
+ if (typeof rec.uuid === "string" && rec.uuid.length > 0)
358
+ return rec.uuid;
359
+ }
360
+ return undefined;
361
+ }
362
+ /**
363
+ * The `uuid` of a transcript's last record — the thing the ancestor test asks
364
+ * about. One 32 KB tail read, widened once for a transcript whose final record
365
+ * is a multi-megabyte tool result.
366
+ */
367
+ async function lastRecordUuid(file, size) {
368
+ if (size === 0)
369
+ return undefined;
370
+ try {
371
+ const near = await readWindow(file, size, TAIL_WINDOW_BYTES);
372
+ const found = lastUuidIn(near.text, near.whole);
373
+ if (found !== undefined || near.whole)
374
+ return found;
375
+ const far = await readWindow(file, size, WIDE_TAIL_BYTES);
376
+ return lastUuidIn(far.text, far.whole);
377
+ }
378
+ catch {
379
+ return undefined;
380
+ }
381
+ }
382
+ /**
383
+ * Stage 2: does `uuid` appear in a 32 KB window of `file` ENDING at byte
384
+ * `offset`?
385
+ *
386
+ * This is the measurement the whole cost model rests on. A transcript is
387
+ * append-only, so if the shorter file really is an ancestor of this one, its
388
+ * last record ends exactly at byte `shorter.size` here — the uuid is at a known
389
+ * offset, and finding it needs a read of one window rather than a scan of the
390
+ * file. 298/300 hits and zero misses over the real corpus (design §0.3); the
391
+ * two non-hits were files whose final record exceeded the window, which fall
392
+ * through to stage 3 and are classified correctly there.
393
+ */
394
+ async function probeAtOffset(file, uuid, offset) {
395
+ if (offset <= 0)
396
+ return false;
397
+ try {
398
+ const { text } = await readWindow(file, offset, PROBE_WINDOW_BYTES);
399
+ return text.includes(uuid);
400
+ }
401
+ catch {
402
+ return false;
403
+ }
404
+ }
405
+ /**
406
+ * Stage 3: raw chunked substring scan. No JSON parsing — the question is only
407
+ * "does this uuid occur anywhere", and parsing 256 MB to answer it would cost
408
+ * several times the read.
409
+ *
410
+ * Chunks overlap by the needle's length so a uuid straddling a boundary is
411
+ * still found.
412
+ */
413
+ async function fullScanFor(file, uuid) {
414
+ const stream = createReadStream(file, {
415
+ encoding: "utf8",
416
+ highWaterMark: SCAN_CHUNK_BYTES,
417
+ });
418
+ let carry = "";
419
+ try {
420
+ for await (const chunk of stream) {
421
+ const text = carry + chunk;
422
+ if (text.includes(uuid))
423
+ return true;
424
+ carry = text.slice(-uuid.length);
425
+ }
426
+ }
427
+ catch {
428
+ return false;
429
+ }
430
+ finally {
431
+ stream.destroy();
432
+ }
433
+ return false;
434
+ }
435
+ /**
436
+ * Parse a transcript for the comparison columns: how many real messages it
437
+ * holds and when the last one was. Same `isConversation` rule as the chat
438
+ * list's ordering (#863), imported rather than re-stated — a second copy of
439
+ * that rule would drift and the two numbers would disagree on screen.
440
+ */
441
+ async function readSideDetail(file) {
442
+ let messageCount = 0;
443
+ let lastMessageAt;
444
+ const stream = createReadStream(file, { encoding: "utf8" });
445
+ let carry = "";
446
+ const consume = (line) => {
447
+ const trimmed = line.trim();
448
+ if (!trimmed)
449
+ return;
450
+ let rec;
451
+ try {
452
+ rec = JSON.parse(trimmed);
453
+ }
454
+ catch {
455
+ return;
456
+ }
457
+ if (!isConversationRecord(rec))
458
+ return;
459
+ messageCount++;
460
+ const ts = rec.timestamp;
461
+ if (typeof ts === "string" && !Number.isNaN(Date.parse(ts)))
462
+ lastMessageAt = ts;
463
+ };
464
+ try {
465
+ for await (const chunk of stream) {
466
+ const parts = (carry + chunk).split("\n");
467
+ carry = parts.pop() ?? "";
468
+ for (const line of parts)
469
+ consume(line);
470
+ }
471
+ consume(carry);
472
+ }
473
+ catch {
474
+ /* a partial count is still better than none */
475
+ }
476
+ finally {
477
+ stream.destroy();
478
+ }
479
+ return lastMessageAt === undefined ? { messageCount } : { messageCount, lastMessageAt };
480
+ }
481
+ async function sideOf(file) {
482
+ const st = await fs.stat(file).catch(() => null);
483
+ if (!st)
484
+ return null;
485
+ return { path: file, sizeBytes: st.size, mtime: st.mtime.toISOString() };
486
+ }
487
+ /**
488
+ * Build the modal's plan: every chat in every project's `.chats/`, classified
489
+ * against its host store.
490
+ *
491
+ * Chats identical on both sides are omitted from the rows — there is no
492
+ * decision to make — but they are counted in `totals.identical` so a row count
493
+ * lower than the user's chat total always has an explanation. They still
494
+ * migrate: the postcondition is about `.chats/` being empty, not about which
495
+ * chats the user picked.
496
+ */
497
+ export async function buildMigrationPlan(input) {
498
+ const warnings = [];
499
+ const projects = [];
500
+ const candidates = [];
501
+ let identical = 0;
502
+ if (input.envShadowed) {
503
+ warnings.push({
504
+ code: "env-shadowed",
505
+ message: `${TRANSCRIPTS_ENV_VAR} is set in the environment, which overrides the config ` +
506
+ `file. Writing claude.transcripts: host would have no effect until it is unset.`,
507
+ });
508
+ }
509
+ for (const project of input.projects) {
510
+ const scan = await scanProject(project, input);
511
+ if (scan === null)
512
+ continue;
513
+ warnings.push(...scan.warnings);
514
+ candidates.push(...scan.candidates);
515
+ const rows = scan.rows.filter((r) => !r.identical);
516
+ identical += scan.rows.length - rows.length;
517
+ for (const r of rows)
518
+ delete r.identical;
519
+ if (rows.length > 0 || scan.projectExtras.length > 0) {
520
+ projects.push({
521
+ slug: project.slug,
522
+ name: project.name,
523
+ chatsDir: scan.chatsDir,
524
+ hostStore: scan.hostStore,
525
+ preserveDir: scan.preserveDir,
526
+ chats: rows,
527
+ projectExtras: scan.projectExtras,
528
+ });
529
+ }
530
+ }
531
+ const scanBudgetExhausted = await settleCandidates(candidates, input.scanBudgetBytes ?? DIVERGENCE_SCAN_BUDGET_BYTES);
532
+ const totals = {
533
+ chats: 0,
534
+ new: 0,
535
+ fastForward: 0,
536
+ diverged: 0,
537
+ unknown: 0,
538
+ identical,
539
+ defaultSelected: 0,
540
+ };
541
+ for (const p of projects) {
542
+ for (const c of p.chats) {
543
+ totals.chats++;
544
+ if (c.state === "new")
545
+ totals.new++;
546
+ else if (c.state === "fast-forward")
547
+ totals.fastForward++;
548
+ else if (c.state === "diverged")
549
+ totals.diverged++;
550
+ else
551
+ totals.unknown++;
552
+ if (c.defaultSelected)
553
+ totals.defaultSelected++;
554
+ }
555
+ }
556
+ return {
557
+ mode: input.mode,
558
+ configPath: input.configPath,
559
+ configVersion: input.configVersion,
560
+ projects,
561
+ sweepers: await countSweepers(input),
562
+ totals,
563
+ scanBudgetExhausted,
564
+ warnings,
565
+ };
566
+ }
567
+ /**
568
+ * Classify one project's `.chats/` against its host store.
569
+ *
570
+ * Returns `null` when there is nothing there to migrate: no `.chats/`, an
571
+ * already-planted symlink, or an empty directory. Everything else — including
572
+ * an unreadable store — comes back as a scan carrying its own warnings, because
573
+ * the execute half has to decide what to do about it rather than have it
574
+ * swallowed here.
575
+ *
576
+ * The stage-3 candidates are returned UNSETTLED. Settling them is one shared
577
+ * budget across the whole request, so the caller has to own it — see
578
+ * {@link settleCandidates}.
579
+ */
580
+ export async function scanProject(project, input) {
581
+ const warnings = [];
582
+ const candidates = [];
583
+ {
584
+ const chatsDir = projectChatsDir(project.dir);
585
+ const hostStore = encodedTranscriptDir(input.userHome, project.workingDir);
586
+ const read = await readChatsDir(chatsDir);
587
+ if (read === null)
588
+ return null;
589
+ if ("error" in read) {
590
+ return {
591
+ ref: project,
592
+ chatsDir,
593
+ hostStore,
594
+ preserveDir: path.join(project.dir, PRESERVE_DIR_NAME),
595
+ listing: {
596
+ chats: [],
597
+ chatDirs: new Set(),
598
+ projectExtras: [],
599
+ unexpected: [],
600
+ hasReverts: false,
601
+ hasMemory: false,
602
+ entryCount: -1,
603
+ },
604
+ hostReadable: false,
605
+ revertsByChat: new Map(),
606
+ rows: [],
607
+ projectExtras: [],
608
+ warnings: [
609
+ {
610
+ code: "chats-dir-unreadable",
611
+ slug: project.slug,
612
+ message: `Could not read ${chatsDir}: ${read.error.message}`,
613
+ paths: [chatsDir],
614
+ },
615
+ ],
616
+ candidates: [],
617
+ };
618
+ }
619
+ const listing = read.listing;
620
+ if (listing.entryCount === 0)
621
+ return null;
622
+ // Stage 0's other half. ENOENT is the normal case — the user has never run
623
+ // `claude` in this directory — and means every chat is `new`.
624
+ let hostEntries = [];
625
+ try {
626
+ hostEntries = await fs.readdir(hostStore);
627
+ }
628
+ catch (err) {
629
+ const e = err;
630
+ if (e.code === "ENOENT") {
631
+ hostEntries = [];
632
+ }
633
+ else {
634
+ // We cannot tell `new` from `diverged` without this. Reporting every
635
+ // chat as `new` would default them all to CHECKED on the strength of a
636
+ // read that failed, so the rows go to `unknown` instead — which already
637
+ // means "not settled" and already defaults to unchecked.
638
+ hostEntries = null;
639
+ warnings.push({
640
+ code: "host-store-unreadable",
641
+ slug: project.slug,
642
+ message: `Could not read ${hostStore}: ${e.message}. Every chat in this project is ` +
643
+ `reported as \`unknown\` because new and diverged cannot be told apart ` +
644
+ `without it.`,
645
+ paths: [hostStore],
646
+ });
647
+ }
648
+ }
649
+ const hostChats = new Set((hostEntries ?? [])
650
+ .filter((n) => n.endsWith(".jsonl") && SESSION_ID_RE.test(n.slice(0, -6)))
651
+ .map((n) => n.slice(0, -6)));
652
+ // `.reverts/` is shared across sessions (`herdctl.ts:1988`), so a partial
653
+ // migration has to split it by prefix. One readdir, grouped once.
654
+ const revertsByChat = new Map();
655
+ if (listing.hasReverts) {
656
+ const revertsDir = path.join(chatsDir, ".reverts");
657
+ const chatIds = new Set(listing.chats);
658
+ for (const name of await fs.readdir(revertsDir).catch(() => [])) {
659
+ // `<id>-<stamp>.jsonl`, where the id itself contains dashes. Resolved by
660
+ // testing each dash position against the ids actually present rather
661
+ // than by assuming a 36-char UUID — session ids are only guaranteed to
662
+ // match `[A-Za-z0-9._-]+`, and a fixed offset silently mis-files every
663
+ // revert belonging to a chat named anything else.
664
+ const owner = ownerOfRevert(name, chatIds);
665
+ if (owner === undefined) {
666
+ // A revert for a chat that is no longer here. It still has to move —
667
+ // the postcondition is about the directory, not about the rows.
668
+ listing.projectExtras.push(path.join(revertsDir, name));
669
+ continue;
670
+ }
671
+ // BASE NAMES, not paths: the execute half replays them against BOTH
672
+ // store roots (a superseded host copy has its own `.reverts/<name>`),
673
+ // and a path baked to `.chats/` cannot be replayed against `~/.claude`.
674
+ const list = revertsByChat.get(owner) ?? [];
675
+ list.push(name);
676
+ revertsByChat.set(owner, list);
677
+ }
678
+ }
679
+ if (listing.hasMemory) {
680
+ await collectMemoryCollisions(chatsDir, hostStore, project.slug, warnings);
681
+ }
682
+ if (listing.unexpected.length > 0) {
683
+ warnings.push({
684
+ code: "unexpected-entries",
685
+ slug: project.slug,
686
+ message: `${listing.unexpected.length} ` +
687
+ (listing.unexpected.length === 1 ? "entry" : "entries") +
688
+ ` in ${chatsDir}: neither a transcript nor a known sidecar. Still migrated — ` +
689
+ `\`.chats/\` has to end up empty for the redirect symlink to be planted — and ` +
690
+ `named here rather than moving unannounced.`,
691
+ paths: listing.unexpected,
692
+ });
693
+ }
694
+ const rows = [];
695
+ for (const sessionId of listing.chats.sort()) {
696
+ const ownPath = path.join(chatsDir, `${sessionId}.jsonl`);
697
+ const own = await sideOf(ownPath);
698
+ if (!own)
699
+ continue; // vanished between readdir and stat
700
+ const extras = [];
701
+ if (listing.chatDirs.has(sessionId)) {
702
+ const dir = path.join(chatsDir, sessionId);
703
+ const kids = await fs.readdir(dir).catch(() => []);
704
+ extras.push(...(kids.length ? kids.map((k) => path.join(dir, k)) : [dir]));
705
+ }
706
+ extras.push(...(revertsByChat.get(sessionId) ?? []).map((n) => path.join(chatsDir, ".reverts", n)));
707
+ const meta = input.names?.get(sessionId);
708
+ const row = {
709
+ sessionId,
710
+ ...(meta?.name ? { name: meta.name } : {}),
711
+ ...(meta?.preview ? { preview: meta.preview } : {}),
712
+ state: "new",
713
+ defaultSelected: true,
714
+ own,
715
+ extras,
716
+ };
717
+ if (hostEntries === null) {
718
+ row.state = "unknown";
719
+ row.defaultSelected = false;
720
+ rows.push(row);
721
+ continue;
722
+ }
723
+ if (!hostChats.has(sessionId)) {
724
+ rows.push(row); // `new` — settled with no stat of the other side, no read
725
+ continue;
726
+ }
727
+ const host = await sideOf(path.join(hostStore, `${sessionId}.jsonl`));
728
+ if (!host) {
729
+ rows.push(row);
730
+ continue;
731
+ }
732
+ row.host = host;
733
+ // Stage 1.
734
+ if (own.sizeBytes === host.sizeBytes) {
735
+ if (own.mtime === host.mtime) {
736
+ row.identical = true;
737
+ rows.push(row);
738
+ continue;
739
+ }
740
+ // Same length, different mtime. Append-only means an ancestor is
741
+ // strictly shorter unless it is the same content, so one tail read each
742
+ // settles it without any scan: same last uuid ⇒ same file.
743
+ const [a, b] = await Promise.all([
744
+ lastRecordUuid(own.path, own.sizeBytes),
745
+ lastRecordUuid(host.path, host.sizeBytes),
746
+ ]);
747
+ if (a !== undefined && a === b) {
748
+ row.identical = true;
749
+ rows.push(row);
750
+ continue;
751
+ }
752
+ row.state = "diverged";
753
+ row.defaultSelected = false;
754
+ candidates.push({
755
+ row,
756
+ longerPath: own.path,
757
+ longerBytes: own.sizeBytes,
758
+ shorterPath: host.path,
759
+ shorterBytes: host.sizeBytes,
760
+ longerSide: "own",
761
+ uuid: undefined, // already settled; queued only for its detail columns
762
+ });
763
+ rows.push(row);
764
+ continue;
765
+ }
766
+ const ownLonger = own.sizeBytes > host.sizeBytes;
767
+ const longer = ownLonger ? own : host;
768
+ const shorter = ownLonger ? host : own;
769
+ // Stage 2 — the bounded probe at the known offset.
770
+ const uuid = await lastRecordUuid(shorter.path, shorter.sizeBytes);
771
+ if (uuid !== undefined && (await probeAtOffset(longer.path, uuid, shorter.sizeBytes))) {
772
+ row.state = "fast-forward";
773
+ row.defaultSelected = true;
774
+ row.ahead = ownLonger ? "own" : "host";
775
+ rows.push(row);
776
+ continue;
777
+ }
778
+ // Stage 3, deferred: it is the only unbounded read in the classifier, so
779
+ // the candidates are collected and run smallest-first against a shared
780
+ // budget rather than each one spending whatever it likes.
781
+ row.state = "diverged";
782
+ row.defaultSelected = false;
783
+ candidates.push({
784
+ row,
785
+ longerPath: longer.path,
786
+ longerBytes: longer.sizeBytes,
787
+ shorterPath: shorter.path,
788
+ shorterBytes: shorter.sizeBytes,
789
+ longerSide: ownLonger ? "own" : "host",
790
+ uuid,
791
+ });
792
+ rows.push(row);
793
+ }
794
+ return {
795
+ ref: project,
796
+ chatsDir,
797
+ hostStore,
798
+ preserveDir: path.join(project.dir, PRESERVE_DIR_NAME),
799
+ listing,
800
+ hostReadable: hostEntries !== null,
801
+ revertsByChat,
802
+ rows,
803
+ projectExtras: listing.projectExtras,
804
+ warnings,
805
+ candidates,
806
+ };
807
+ }
808
+ }
809
+ /**
810
+ * Stage 3 over the deferred candidates, smallest-first against one shared
811
+ * budget. Ascending order means the budget settles as many rows as it can
812
+ * before it runs out, and — because it is ascending — the first row that does
813
+ * not fit proves every later one does not either.
814
+ *
815
+ * Returns whether any row came back `unknown`.
816
+ */
817
+ export async function settleCandidates(candidates, budgetBytes) {
818
+ let remaining = budgetBytes;
819
+ let exhausted = false;
820
+ candidates.sort((a, b) => a.longerBytes - b.longerBytes);
821
+ for (const c of candidates) {
822
+ if (c.uuid !== undefined) {
823
+ if (remaining < c.longerBytes) {
824
+ exhausted = true;
825
+ c.row.state = "unknown";
826
+ c.row.defaultSelected = false;
827
+ continue;
828
+ }
829
+ remaining -= c.longerBytes;
830
+ if (await fullScanFor(c.longerPath, c.uuid)) {
831
+ // A rewritten history: the ancestor's tip is in there, just not where
832
+ // an append-only file would have put it. Still lossless.
833
+ c.row.state = "fast-forward";
834
+ c.row.defaultSelected = true;
835
+ c.row.ahead = c.longerSide;
836
+ continue;
837
+ }
838
+ }
839
+ // Confirmed diverged. The comparison columns are what makes the user's
840
+ // choice informed, and they cost a parse of both sides — charged to the
841
+ // same budget, and simply omitted when it will not stretch. An absent
842
+ // `messageCount` is a legitimate answer meaning "we stopped reading".
843
+ const detailCost = c.longerBytes + c.shorterBytes;
844
+ if (remaining < detailCost) {
845
+ exhausted = true;
846
+ continue;
847
+ }
848
+ remaining -= detailCost;
849
+ const [ownDetail, hostDetail] = await Promise.all([
850
+ readSideDetail(c.row.own.path),
851
+ c.row.host ? readSideDetail(c.row.host.path) : Promise.resolve(null),
852
+ ]);
853
+ Object.assign(c.row.own, ownDetail);
854
+ if (c.row.host && hostDetail)
855
+ Object.assign(c.row.host, hostDetail);
856
+ }
857
+ return exhausted;
858
+ }
859
+ /**
860
+ * `memory/` is merged at FILE granularity and never overwrites anything in the
861
+ * user's real `~/.claude` (§10.2). This PR only reads, but the collisions have
862
+ * to be surfaced now: `memory/MEMORY.md` is a single well-known path, so on the
863
+ * machines this feature is aimed at a collision is the COMMON case — and the
864
+ * skip-if-present rule the transcripts use would leave `memory/` sitting in
865
+ * `.chats/`, fail the postcondition, and block the flip for the whole project.
866
+ */
867
+ async function collectMemoryCollisions(chatsDir, hostStore, slug, warnings) {
868
+ const from = path.join(chatsDir, "memory");
869
+ const to = path.join(hostStore, "memory");
870
+ const collisions = [];
871
+ const walk = async (rel) => {
872
+ const entries = await fs
873
+ .readdir(path.join(from, rel), { withFileTypes: true })
874
+ .catch(() => []);
875
+ for (const e of entries) {
876
+ const next = path.join(rel, e.name);
877
+ if (e.isDirectory()) {
878
+ await walk(next);
879
+ continue;
880
+ }
881
+ const dest = path.join(to, next);
882
+ if (await fs.lstat(dest).then(() => true).catch(() => false)) {
883
+ collisions.push(path.join(from, next), dest);
884
+ }
885
+ }
886
+ };
887
+ await walk("");
888
+ if (collisions.length === 0)
889
+ return;
890
+ warnings.push({
891
+ code: "memory-collision",
892
+ slug,
893
+ message: `${collisions.length / 2} agent-memory file(s) already exist in the host store. ` +
894
+ `Nothing in your own ~/.claude is ever overwritten: Paddock's copies are set ` +
895
+ `aside under ${path.join(path.dirname(chatsDir), PRESERVE_DIR_NAME, "memory")} ` +
896
+ `for you to merge by hand.`,
897
+ paths: collisions,
898
+ });
899
+ }
900
+ /**
901
+ * Sweeper stores (`<dataDir>/sweepers/<slug>/.chats`), which get their own
902
+ * redirect symlink exactly like a project's and would be split by a
903
+ * project-only migration. Migrated silently with their project — no rows, no
904
+ * user choice (#882) — so they are reported as counts alone.
905
+ */
906
+ async function countSweepers(input) {
907
+ let stores = 0;
908
+ let chats = 0;
909
+ for (const dir of input.sweeperDirs?.values() ?? []) {
910
+ const read = await readChatsDir(projectChatsDir(dir));
911
+ if (read === null || "error" in read || read.listing.entryCount === 0)
912
+ continue;
913
+ stores++;
914
+ chats += read.listing.chats.length;
915
+ }
916
+ return { stores, chats };
917
+ }