@ddtcorex/dsh-maestro-memory 1.0.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 (109) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +448 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +756 -0
  6. package/lib/index.d.ts +10 -0
  7. package/lib/index.d.ts.map +1 -0
  8. package/lib/index.js +627 -0
  9. package/lib/index.js.map +1 -0
  10. package/lib/memory/batch.d.ts +44 -0
  11. package/lib/memory/batch.d.ts.map +1 -0
  12. package/lib/memory/batch.js +75 -0
  13. package/lib/memory/batch.js.map +1 -0
  14. package/lib/memory/feedback.d.ts +18 -0
  15. package/lib/memory/feedback.d.ts.map +1 -0
  16. package/lib/memory/feedback.js +29 -0
  17. package/lib/memory/feedback.js.map +1 -0
  18. package/lib/memory/store.d.ts +154 -0
  19. package/lib/memory/store.d.ts.map +1 -0
  20. package/lib/memory/store.js +606 -0
  21. package/lib/memory/store.js.map +1 -0
  22. package/lib/migration/cli.d.ts +18 -0
  23. package/lib/migration/cli.d.ts.map +1 -0
  24. package/lib/migration/cli.js +143 -0
  25. package/lib/migration/cli.js.map +1 -0
  26. package/lib/migration/fixture.d.ts +45 -0
  27. package/lib/migration/fixture.d.ts.map +1 -0
  28. package/lib/migration/fixture.js +177 -0
  29. package/lib/migration/fixture.js.map +1 -0
  30. package/lib/migration/service.d.ts +75 -0
  31. package/lib/migration/service.d.ts.map +1 -0
  32. package/lib/migration/service.js +713 -0
  33. package/lib/migration/service.js.map +1 -0
  34. package/lib/prompt/snapshot.d.ts +26 -0
  35. package/lib/prompt/snapshot.d.ts.map +1 -0
  36. package/lib/prompt/snapshot.js +74 -0
  37. package/lib/prompt/snapshot.js.map +1 -0
  38. package/lib/review/queue.d.ts +72 -0
  39. package/lib/review/queue.d.ts.map +1 -0
  40. package/lib/review/queue.js +306 -0
  41. package/lib/review/queue.js.map +1 -0
  42. package/lib/skills-browser.d.ts +67 -0
  43. package/lib/skills-browser.d.ts.map +1 -0
  44. package/lib/skills-browser.js +185 -0
  45. package/lib/skills-browser.js.map +1 -0
  46. package/lib/storage/atomic-store.d.ts +211 -0
  47. package/lib/storage/atomic-store.d.ts.map +1 -0
  48. package/lib/storage/atomic-store.js +582 -0
  49. package/lib/storage/atomic-store.js.map +1 -0
  50. package/lib/storage/layout.d.ts +81 -0
  51. package/lib/storage/layout.d.ts.map +1 -0
  52. package/lib/storage/layout.js +162 -0
  53. package/lib/storage/layout.js.map +1 -0
  54. package/lib/storage/legacy-format.d.ts +104 -0
  55. package/lib/storage/legacy-format.d.ts.map +1 -0
  56. package/lib/storage/legacy-format.js +262 -0
  57. package/lib/storage/legacy-format.js.map +1 -0
  58. package/lib/sync/config.d.ts +22 -0
  59. package/lib/sync/config.d.ts.map +1 -0
  60. package/lib/sync/config.js +57 -0
  61. package/lib/sync/config.js.map +1 -0
  62. package/lib/sync/git.d.ts +97 -0
  63. package/lib/sync/git.d.ts.map +1 -0
  64. package/lib/sync/git.js +282 -0
  65. package/lib/sync/git.js.map +1 -0
  66. package/lib/sync/index.d.ts +6 -0
  67. package/lib/sync/index.d.ts.map +1 -0
  68. package/lib/sync/index.js +6 -0
  69. package/lib/sync/index.js.map +1 -0
  70. package/lib/sync/layout.d.ts +15 -0
  71. package/lib/sync/layout.d.ts.map +1 -0
  72. package/lib/sync/layout.js +33 -0
  73. package/lib/sync/layout.js.map +1 -0
  74. package/lib/sync/merge.d.ts +42 -0
  75. package/lib/sync/merge.d.ts.map +1 -0
  76. package/lib/sync/merge.js +159 -0
  77. package/lib/sync/merge.js.map +1 -0
  78. package/lib/sync/service.d.ts +70 -0
  79. package/lib/sync/service.d.ts.map +1 -0
  80. package/lib/sync/service.js +508 -0
  81. package/lib/sync/service.js.map +1 -0
  82. package/lib/todo/store.d.ts +142 -0
  83. package/lib/todo/store.d.ts.map +1 -0
  84. package/lib/todo/store.js +452 -0
  85. package/lib/todo/store.js.map +1 -0
  86. package/lib/types/client/index.d.ts +7 -0
  87. package/lib/types/client/index.d.ts.map +1 -0
  88. package/package.json +63 -0
  89. package/src/client/index.tsx +994 -0
  90. package/src/host/index.ts +591 -0
  91. package/src/host/memory/batch.ts +107 -0
  92. package/src/host/memory/feedback.ts +36 -0
  93. package/src/host/memory/store.ts +650 -0
  94. package/src/host/migration/cli.ts +139 -0
  95. package/src/host/migration/fixture.ts +218 -0
  96. package/src/host/migration/service.ts +786 -0
  97. package/src/host/prompt/snapshot.ts +95 -0
  98. package/src/host/review/queue.ts +325 -0
  99. package/src/host/skills-browser.ts +191 -0
  100. package/src/host/storage/atomic-store.ts +605 -0
  101. package/src/host/storage/layout.ts +191 -0
  102. package/src/host/storage/legacy-format.ts +308 -0
  103. package/src/host/sync/config.ts +67 -0
  104. package/src/host/sync/git.ts +280 -0
  105. package/src/host/sync/index.ts +5 -0
  106. package/src/host/sync/layout.ts +39 -0
  107. package/src/host/sync/merge.ts +188 -0
  108. package/src/host/sync/service.ts +494 -0
  109. package/src/host/todo/store.ts +480 -0
@@ -0,0 +1,494 @@
1
+ /**
2
+ * sync/service.ts — enable/disable/status/fetch/push/pull/resolve
3
+ * Disabled => zero Git activity (no spawn).
4
+ */
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, rmSync, readdirSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { randomBytes } from 'node:crypto'
8
+ import { resolveMemoryRoot, maestroMetaDir } from '../storage/layout.ts'
9
+ import { projectHash, projectKeyPath, projectMemoryPath, projectKeyArchivePath, projectTodoPath } from '../storage/layout.ts'
10
+ import { parseEntries, serializeEntries } from '../storage/atomic-store.ts'
11
+ import { parseTodoEntry, TODO_HEADER, ENTRY_DELIMITER } from '../storage/legacy-format.ts'
12
+ import { readEntriesSync, writeEntriesAtomicSync } from '../storage/atomic-store.ts'
13
+ import { syncBranchName, syncConflictsPath, syncDir } from './layout.ts'
14
+ import { readConfig, writeConfig, clearConfig, readMeta, writeMeta, baseIdsFromMeta, SyncConfig, SyncMeta } from './config.ts'
15
+ import { mergeMemoryEntries, mergeTodoEntries, snapshotIds, mapById } from './merge.ts'
16
+ import type { GitAdapter } from './git.ts'
17
+ import { MockGitAdapter, RealGitAdapter } from './git.ts'
18
+
19
+ const TRACK_FILES: Record<string, (root: string, cwd: string) => string> = {
20
+ KEY: (root, cwd) => projectKeyPath(root, cwd),
21
+ MEMORY: (root, cwd) => projectMemoryPath(root, cwd),
22
+ 'KEY-archive': (root, cwd) => projectKeyArchivePath(root, cwd),
23
+ TODOS: (root, cwd) => projectTodoPath(root, cwd),
24
+ }
25
+
26
+ function ensureIdForMemoryEntries(entries: string[]): { entries: string[]; changed: boolean } {
27
+ let changed = false
28
+ const next = entries.map((e) => {
29
+ if (/^\[id:\s*[0-9a-f]{8}\]\s*/i.test(e)) return e
30
+ changed = true
31
+ const id = randomBytes(4).toString('hex')
32
+ return `[id:${id}] ${e}`
33
+ })
34
+ return { entries: next, changed }
35
+ }
36
+
37
+ function readMemoryTrack(root: string, cwd: string, track: string): string[] {
38
+ const fn = TRACK_FILES[track]
39
+ if (!fn) return []
40
+ const p = fn(root, cwd)
41
+ return readEntriesSync(p)
42
+ }
43
+
44
+ function writeMemoryTrack(root: string, cwd: string, track: string, entries: string[]): void {
45
+ const fn = TRACK_FILES[track]
46
+ if (!fn) throw new Error(`unknown track ${track}`)
47
+ const p = fn(root, cwd)
48
+ const res = writeEntriesAtomicSync(p, entries)
49
+ if (!res.ok) throw new Error(res.error)
50
+ }
51
+
52
+ function readTodoTrackRaw(root: string, cwd: string): string[] {
53
+ const p = projectTodoPath(root, cwd)
54
+ let text = ''
55
+ try { text = readFileSync(p, 'utf8') } catch (e: any) { if (e?.code === 'ENOENT') return []; throw e }
56
+ const body = text.replace(/^<!--[\s\S]*?-->\s*/, '').replace(/^\s*§\s*\n?/, '').trim()
57
+ if (body === '') return []
58
+ return body.split(ENTRY_DELIMITER).map((s) => s.trim()).filter(Boolean)
59
+ }
60
+
61
+ function todoIds(entries: string[]): Set<string> {
62
+ const s = new Set<string>()
63
+ for (const e of entries) {
64
+ const parsed = parseTodoEntry(e)
65
+ if (parsed?.id) s.add(parsed.id.toLowerCase())
66
+ }
67
+ return s
68
+ }
69
+
70
+ function extractId(entry: string): string | null {
71
+ const m = /^\[id:\s*([0-9a-f]{8})\]\s*/i.exec(entry)
72
+ if (m) return m[1].toLowerCase()
73
+ // for todos, id is inside first line
74
+ const pm = parseTodoEntry(entry)
75
+ if (pm?.id) return pm.id.toLowerCase()
76
+ return null
77
+ }
78
+
79
+ function isWriteBlockedSync(root: string): boolean {
80
+ try {
81
+ const p = join(maestroMetaDir(root), 'write-block.json')
82
+ if (!existsSync(p)) return false
83
+ const data = JSON.parse(readFileSync(p, 'utf8'))
84
+ return data.blocked === true
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+
90
+ export class SyncService {
91
+ constructor(
92
+ private readonly memoryDir: string | null = null,
93
+ private readonly git: GitAdapter = new MockGitAdapter(),
94
+ ) {}
95
+
96
+ private root(): string { return resolveMemoryRoot(this.memoryDir) }
97
+
98
+ private hashFor(cwd: string): string { return projectHash(cwd) }
99
+
100
+ // -------------------------------------------------------------------------
101
+ // enable
102
+ // -------------------------------------------------------------------------
103
+ enable(cwd: string, remoteUrl: string, branch?: string): { ok: true; hash: string } | { ok: false; error: string } {
104
+ if (!cwd) return { ok: false, error: 'cwd required' }
105
+ if (!remoteUrl || typeof remoteUrl !== 'string' || remoteUrl.trim() === '') return { ok: false, error: 'remoteUrl required' }
106
+ const hash = this.hashFor(cwd)
107
+ const root = this.root()
108
+ const br = branch?.trim() || syncBranchName(hash)
109
+ const cfg: SyncConfig = { enabled: true, remoteUrl: remoteUrl.trim(), branch: br }
110
+ writeConfig(root, hash, cfg)
111
+
112
+ // Adopt: ensure all synced entries have ids (KEY, MEMORY, KEY-archive). TODOS already have ids.
113
+ for (const track of ['KEY', 'MEMORY', 'KEY-archive']) {
114
+ const entries = readMemoryTrack(root, cwd, track)
115
+ const { entries: next, changed } = ensureIdForMemoryEntries(entries)
116
+ if (changed) {
117
+ writeMemoryTrack(root, cwd, track, next)
118
+ }
119
+ }
120
+ // For TODOS, ensure any entry missing id gets one? Usually not needed. Skip.
121
+
122
+ return { ok: true, hash }
123
+ }
124
+
125
+ disable(cwd: string): { ok: true } | { ok: false; error: string } {
126
+ if (!cwd) return { ok: false, error: 'cwd required' }
127
+ const hash = this.hashFor(cwd)
128
+ clearConfig(this.root(), hash)
129
+ return { ok: true }
130
+ }
131
+
132
+ isEnabled(cwd: string): boolean {
133
+ const hash = this.hashFor(cwd)
134
+ return readConfig(this.root(), hash) !== null
135
+ }
136
+
137
+ // -------------------------------------------------------------------------
138
+ // status
139
+ // -------------------------------------------------------------------------
140
+ status(cwd: string, reveal = false): { enabled: boolean; branch?: string; remoteUrl?: string; remoteRedacted?: string; lastSync?: SyncMeta | null; conflicts: any[] } {
141
+ const hash = this.hashFor(cwd)
142
+ const root = this.root()
143
+ const cfg = readConfig(root, hash)
144
+ if (!cfg) {
145
+ return { enabled: false, conflicts: [] }
146
+ }
147
+ const meta = readMeta(root, hash)
148
+ const conflicts = this.listConflicts(cwd)
149
+ const remoteRedacted = '***'
150
+ return {
151
+ enabled: true,
152
+ branch: cfg.branch,
153
+ remoteUrl: reveal ? cfg.remoteUrl : undefined,
154
+ remoteRedacted: reveal ? undefined : remoteRedacted,
155
+ lastSync: meta,
156
+ conflicts,
157
+ }
158
+ }
159
+
160
+ listConflicts(cwd: string): any[] {
161
+ const hash = this.hashFor(cwd)
162
+ const p = syncConflictsPath(this.root(), hash)
163
+ if (!existsSync(p)) return []
164
+ try {
165
+ const txt = readFileSync(p, 'utf8')
166
+ return txt.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
167
+ } catch { return [] }
168
+ }
169
+
170
+ private writeConflicts(cwd: string, conflicts: any[]): void {
171
+ const hash = this.hashFor(cwd)
172
+ const p = syncConflictsPath(this.root(), hash)
173
+ mkdirSync(dirname(p), { recursive: true })
174
+ const lines = conflicts.map((c) => JSON.stringify(c)).join('\n')
175
+ writeFileSync(p, lines ? lines + '\n' : '', 'utf8')
176
+ }
177
+
178
+ private appendConflict(cwd: string, rec: any): void {
179
+ const hash = this.hashFor(cwd)
180
+ const p = syncConflictsPath(this.root(), hash)
181
+ mkdirSync(dirname(p), { recursive: true })
182
+ appendFileSync(p, JSON.stringify(rec) + '\n', 'utf8')
183
+ }
184
+
185
+ // -------------------------------------------------------------------------
186
+ // internal helpers: collect local files as remote map
187
+ // -------------------------------------------------------------------------
188
+ private localFiles(cwd: string): Record<string, string> {
189
+ const root = this.root()
190
+ const out: Record<string, string> = {}
191
+ for (const track of ['KEY', 'MEMORY', 'KEY-archive']) {
192
+ const entries = readMemoryTrack(root, cwd, track)
193
+ out[track === 'KEY' ? 'KEY.md' : track === 'MEMORY' ? 'MEMORY.md' : 'KEY-archive.md'] = serializeEntries(entries)
194
+ }
195
+ // TODOS
196
+ const todoEntries = readTodoTrackRaw(root, cwd)
197
+ const todoText = todoEntries.length ? `${TODO_HEADER}\n§\n${todoEntries.join(ENTRY_DELIMITER)}\n` : `${TODO_HEADER}`
198
+ out['TODOS.md'] = todoText
199
+ return out
200
+ }
201
+
202
+ private writeLocalFiles(cwd: string, files: Record<string, string>): void {
203
+ const root = this.root()
204
+ if (files['KEY.md'] !== undefined) {
205
+ const entries = parseEntries(files['KEY.md'])
206
+ writeMemoryTrack(root, cwd, 'KEY', entries)
207
+ }
208
+ if (files['MEMORY.md'] !== undefined) {
209
+ const entries = parseEntries(files['MEMORY.md'])
210
+ writeMemoryTrack(root, cwd, 'MEMORY', entries)
211
+ }
212
+ if (files['KEY-archive.md'] !== undefined) {
213
+ const entries = parseEntries(files['KEY-archive.md'])
214
+ writeMemoryTrack(root, cwd, 'KEY-archive', entries)
215
+ }
216
+ if (files['TODOS.md'] !== undefined) {
217
+ const text = files['TODOS.md']
218
+ const body = text.replace(/^<!--[\s\S]*?-->\s*/, '').replace(/^\s*§\s*\n?/, '').trim()
219
+ const entries = body ? body.split(ENTRY_DELIMITER).map((s) => s.trim()).filter(Boolean) : []
220
+ const p = projectTodoPath(root, cwd)
221
+ mkdirSync(dirname(p), { recursive: true })
222
+ const out = `${TODO_HEADER}${entries.length ? `\n§\n${entries.join(ENTRY_DELIMITER)}\n` : ''}`
223
+ writeFileSync(p, out, 'utf8')
224
+ }
225
+ }
226
+
227
+ // -------------------------------------------------------------------------
228
+ // fetch (no local mutation)
229
+ // -------------------------------------------------------------------------
230
+ async fetch(cwd: string): Promise<{ ok: true; conflicts: any[]; remoteFiles: Record<string, string>; status: string } | { ok: false; error: string }> {
231
+ const hash = this.hashFor(cwd)
232
+ const root = this.root()
233
+ if (isWriteBlockedSync(root)) return { ok: false, error: `write blocked: migration verify mismatch (see ${join(maestroMetaDir(root), 'write-block.json')})` }
234
+ const cfg = readConfig(root, hash)
235
+ if (!cfg) return { ok: false, error: 'sync disabled for this project' }
236
+ // No local mutation, but we need to check remote
237
+ const fetchRes = await this.git.fetch(cfg.remoteUrl, cfg.branch)
238
+ if (!fetchRes.ok) return { ok: false, error: fetchRes.error }
239
+ const remoteRes = await this.git.getRemoteFiles(cfg.remoteUrl, cfg.branch)
240
+ if (!remoteRes.ok) return { ok: false, error: remoteRes.error }
241
+
242
+ const remoteFiles = remoteRes.files
243
+ // compare local vs remote for conflict preview (do not write files)
244
+ const meta = readMeta(root, hash)
245
+ const conflicts: any[] = []
246
+
247
+ for (const track of ['KEY', 'MEMORY', 'KEY-archive']) {
248
+ const fileKey = track === 'KEY' ? 'KEY.md' : track === 'MEMORY' ? 'MEMORY.md' : 'KEY-archive.md'
249
+ const localEntries = readMemoryTrack(root, cwd, track)
250
+ const remoteText = remoteFiles[fileKey] ?? ''
251
+ const remoteEntries = parseEntries(remoteText)
252
+ const baseIds = baseIdsFromMeta(meta, track)
253
+ const res = mergeMemoryEntries({ track, local: localEntries, remote: remoteEntries, baseIds })
254
+ for (const c of res.conflicts) {
255
+ conflicts.push({ track, id: c.id, localEntry: c.localEntry, remoteEntry: c.remoteEntry, reason: c.reason })
256
+ }
257
+ }
258
+ // TODOS
259
+ {
260
+ const localTodos = readTodoTrackRaw(root, cwd)
261
+ const remoteTodoText = remoteFiles['TODOS.md'] ?? ''
262
+ const remoteBody = remoteTodoText.replace(/^<!--[\s\S]*?-->\s*/, '').replace(/^\s*§\s*\n?/, '').trim()
263
+ const remoteTodos = remoteBody ? remoteBody.split(ENTRY_DELIMITER).map((s) => s.trim()).filter(Boolean) : []
264
+ const metaIds = baseIdsFromMeta(meta, 'TODOS')
265
+ const res = mergeTodoEntries({ local: localTodos, remote: remoteTodos, baseIds: metaIds })
266
+ for (const c of res.conflicts) conflicts.push({ track: 'TODOS', id: c.id, localEntry: c.localEntry, remoteEntry: c.remoteEntry, reason: c.reason })
267
+ }
268
+
269
+ const status = conflicts.length ? 'conflicts' : 'ok'
270
+ return { ok: true, conflicts, remoteFiles, status }
271
+ }
272
+
273
+ // -------------------------------------------------------------------------
274
+ // push (explicit)
275
+ // -------------------------------------------------------------------------
276
+ async push(cwd: string, message?: string): Promise<{ ok: true; pushed: boolean; conflicts: any[] } | { ok: false; error: string; conflicts?: any[] }> {
277
+ const hash = this.hashFor(cwd)
278
+ const root = this.root()
279
+ if (isWriteBlockedSync(root)) return { ok: false, error: `write blocked: migration verify mismatch (see ${join(maestroMetaDir(root), 'write-block.json')})` }
280
+ const cfg = readConfig(root, hash)
281
+ if (!cfg) return { ok: false, error: 'sync disabled for this project' }
282
+
283
+ // First fetch to check conflicts
284
+ const fetchRes = await this.git.fetch(cfg.remoteUrl, cfg.branch)
285
+ if (!fetchRes.ok) return { ok: false, error: fetchRes.error }
286
+ const remoteRes = await this.git.getRemoteFiles(cfg.remoteUrl, cfg.branch)
287
+ if (!remoteRes.ok) return { ok: false, error: remoteRes.error }
288
+
289
+ const remoteFiles = remoteRes.files
290
+ const meta = readMeta(root, hash)
291
+ const allConflicts: any[] = []
292
+
293
+ for (const track of ['KEY', 'MEMORY', 'KEY-archive']) {
294
+ const fileKey = track === 'KEY' ? 'KEY.md' : track === 'MEMORY' ? 'MEMORY.md' : 'KEY-archive.md'
295
+ const localEntries = readMemoryTrack(root, cwd, track)
296
+ const remoteEntries = parseEntries(remoteRes.files[fileKey] ?? '')
297
+ const res = mergeMemoryEntries({ track, local: localEntries, remote: remoteEntries, baseIds: baseIdsFromMeta(meta, track) })
298
+ allConflicts.push(...res.conflicts.map((c) => ({ track, id: c.id, localEntry: c.localEntry, remoteEntry: c.remoteEntry })))
299
+ }
300
+ {
301
+ const localTodos = readTodoTrackRaw(root, cwd)
302
+ const remoteTodos = (() => {
303
+ const txt = remoteRes.files['TODOS.md'] ?? ''
304
+ const body = txt.replace(/^<!--[\s\S]*?-->\s*/, '').replace(/^\s*§\s*\n?/, '').trim()
305
+ return body ? body.split(ENTRY_DELIMITER).map((s) => s.trim()).filter(Boolean) : []
306
+ })()
307
+ const res = mergeTodoEntries({ local: localTodos, remote: remoteTodos, baseIds: baseIdsFromMeta(meta, 'TODOS') })
308
+ allConflicts.push(...res.conflicts.map((c) => ({ track: 'TODOS', id: c.id, localEntry: c.localEntry, remoteEntry: c.remoteEntry })))
309
+ }
310
+
311
+ if (allConflicts.length > 0) {
312
+ // persist conflicts for UI
313
+ this.writeConflicts(cwd, allConflicts.map((c) => ({ ...c, at: new Date().toISOString(), resolved: false })))
314
+ return { ok: false, error: 'conflicts exist, resolve first', conflicts: allConflicts }
315
+ }
316
+
317
+ // No conflicts -> push
318
+ const files = this.localFiles(cwd)
319
+ const msg = message ?? `maestro-memory sync: ${hash} — ${cwd.split('/').pop()} — ${new Date().toISOString()}`
320
+ const pushRes = await this.git.push(cfg.remoteUrl, cfg.branch, files, msg)
321
+ if (!pushRes.ok) return { ok: false, error: pushRes.error }
322
+
323
+ // Update meta snapshot
324
+ const entryIds: Record<string, string[]> = {}
325
+ for (const track of ['KEY', 'MEMORY', 'KEY-archive']) {
326
+ const entries = readMemoryTrack(root, cwd, track)
327
+ entryIds[track] = [...new Set(entries.map((e) => extractId(e)).filter(Boolean) as string[])].sort()
328
+ }
329
+ const todos = readTodoTrackRaw(root, cwd)
330
+ entryIds['TODOS'] = [...new Set(todos.map((e) => extractId(e)).filter(Boolean) as string[])].sort()
331
+
332
+ const newMeta: SyncMeta = {
333
+ hash,
334
+ cwd,
335
+ branch: cfg.branch,
336
+ remoteUrl: cfg.remoteUrl,
337
+ pushedAt: new Date().toISOString(),
338
+ entryIds,
339
+ version: 1,
340
+ }
341
+ writeMeta(root, hash, newMeta)
342
+ // clear conflicts after successful push
343
+ this.writeConflicts(cwd, [])
344
+ return { ok: true, pushed: true, conflicts: [] }
345
+ }
346
+
347
+ // -------------------------------------------------------------------------
348
+ // pull (apply union merge)
349
+ // -------------------------------------------------------------------------
350
+ async pull(cwd: string): Promise<{ ok: true; merged: boolean; conflicts: any[] } | { ok: false; error: string; conflicts?: any[] }> {
351
+ const hash = this.hashFor(cwd)
352
+ const root = this.root()
353
+ if (isWriteBlockedSync(root)) return { ok: false, error: `write blocked: migration verify mismatch (see ${join(maestroMetaDir(root), 'write-block.json')})` }
354
+ const cfg = readConfig(root, hash)
355
+ if (!cfg) return { ok: false, error: 'sync disabled for this project' }
356
+
357
+ const fetchRes = await this.git.fetch(cfg.remoteUrl, cfg.branch)
358
+ if (!fetchRes.ok) return { ok: false, error: fetchRes.error }
359
+ const remoteRes = await this.git.getRemoteFiles(cfg.remoteUrl, cfg.branch)
360
+ if (!remoteRes.ok) return { ok: false, error: remoteRes.error }
361
+
362
+ const meta = readMeta(root, hash)
363
+ const allConflicts: any[] = []
364
+ let didMerge = false
365
+
366
+ for (const track of ['KEY', 'MEMORY', 'KEY-archive']) {
367
+ const fileKey = track === 'KEY' ? 'KEY.md' : track === 'MEMORY' ? 'MEMORY.md' : 'KEY-archive.md'
368
+ const localEntries = readMemoryTrack(root, cwd, track)
369
+ const remoteEntries = parseEntries(remoteRes.files[fileKey] ?? '')
370
+ const res = mergeMemoryEntries({ track, local: localEntries, remote: remoteEntries, baseIds: baseIdsFromMeta(meta, track) })
371
+ if (res.conflicts.length) {
372
+ allConflicts.push(...res.conflicts.map((c) => ({ track, id: c.id, localEntry: c.localEntry, remoteEntry: c.remoteEntry, reason: c.reason })))
373
+ }
374
+ // Write merged (local union plus non-conflicting remote)
375
+ if (res.merged.length !== localEntries.length || res.addedRemote.length > 0) {
376
+ writeMemoryTrack(root, cwd, track, res.merged)
377
+ didMerge = true
378
+ }
379
+ }
380
+
381
+ // TODOS
382
+ {
383
+ const localTodos = readTodoTrackRaw(root, cwd)
384
+ const remoteTodos = (() => {
385
+ const txt = remoteRes.files['TODOS.md'] ?? ''
386
+ const body = txt.replace(/^<!--[\s\S]*?-->\s*/, '').replace(/^\s*§\s*\n?/, '').trim()
387
+ return body ? body.split(ENTRY_DELIMITER).map((s) => s.trim()).filter(Boolean) : []
388
+ })()
389
+ const res = mergeTodoEntries({ local: localTodos, remote: remoteTodos, baseIds: baseIdsFromMeta(meta, 'TODOS') })
390
+ if (res.conflicts.length) allConflicts.push(...res.conflicts.map((c) => ({ track: 'TODOS', id: c.id, localEntry: c.localEntry, remoteEntry: c.remoteEntry, reason: c.reason })))
391
+ if (res.merged.length !== localTodos.length || res.addedRemote.length > 0) {
392
+ // write todos
393
+ const p = projectTodoPath(root, cwd)
394
+ mkdirSync(dirname(p), { recursive: true })
395
+ const out = `${TODO_HEADER}${res.merged.length ? `\n§\n${res.merged.join(ENTRY_DELIMITER)}\n` : ''}`
396
+ writeFileSync(p, out, 'utf8')
397
+ didMerge = true
398
+ }
399
+ }
400
+
401
+ if (allConflicts.length > 0) {
402
+ this.writeConflicts(cwd, allConflicts.map((c) => ({ ...c, at: new Date().toISOString(), resolved: false })))
403
+ // Still considered merged with conflicts pending? Return ok with conflicts
404
+ return { ok: true, merged: didMerge, conflicts: allConflicts }
405
+ }
406
+
407
+ // No conflicts -> update meta to current snapshot
408
+ const entryIds: Record<string, string[]> = {}
409
+ for (const track of ['KEY', 'MEMORY', 'KEY-archive']) {
410
+ const entries = readMemoryTrack(root, cwd, track)
411
+ entryIds[track] = [...new Set(entries.map((e) => extractId(e)).filter(Boolean) as string[])].sort()
412
+ }
413
+ const todos = readTodoTrackRaw(root, cwd)
414
+ entryIds['TODOS'] = [...new Set(todos.map((e) => extractId(e)).filter(Boolean) as string[])].sort()
415
+ writeMeta(root, hash, {
416
+ hash,
417
+ cwd,
418
+ branch: cfg.branch,
419
+ remoteUrl: cfg.remoteUrl,
420
+ pushedAt: new Date().toISOString(),
421
+ entryIds,
422
+ version: 1,
423
+ })
424
+ this.writeConflicts(cwd, [])
425
+ return { ok: true, merged: didMerge, conflicts: [] }
426
+ }
427
+
428
+ // -------------------------------------------------------------------------
429
+ // resolve
430
+ // -------------------------------------------------------------------------
431
+ resolve(cwd: string, id: string, choice: 'local' | 'remote' | 'both'): { ok: true } | { ok: false; error: string } {
432
+ if (!id) return { ok: false, error: 'id required' }
433
+ if (!['local', 'remote', 'both'].includes(choice)) return { ok: false, error: 'invalid choice' }
434
+ const hash = this.hashFor(cwd)
435
+ const root = this.root()
436
+ const cfg = readConfig(root, hash)
437
+ if (!cfg) return { ok: false, error: 'sync disabled' }
438
+ const conflicts = this.listConflicts(cwd)
439
+ const idx = conflicts.findIndex((c) => c.id.toLowerCase() === id.toLowerCase())
440
+ if (idx === -1) return { ok: false, error: `no conflict with id ${id}` }
441
+ const rec = conflicts[idx]
442
+ const track: string = rec.track
443
+
444
+ if (track === 'TODOS') {
445
+ const entries = readTodoTrackRaw(root, cwd)
446
+ const idLC = id.toLowerCase()
447
+ const localIdx = entries.findIndex((e) => (extractId(e) ?? '').toLowerCase() === idLC)
448
+ if (choice === 'local') {
449
+ // keep local, discard remote (do nothing)
450
+ } else if (choice === 'remote') {
451
+ if (localIdx !== -1) {
452
+ // replace local entry with remoteEntry
453
+ entries[localIdx] = rec.remoteEntry
454
+ const p = projectTodoPath(root, cwd)
455
+ writeFileSync(p, `${TODO_HEADER}${entries.length ? `\n§\n${entries.join(ENTRY_DELIMITER)}\n` : ''}`, 'utf8')
456
+ } else {
457
+ // local missing, add remote
458
+ entries.push(rec.remoteEntry)
459
+ const p = projectTodoPath(root, cwd)
460
+ writeFileSync(p, `${TODO_HEADER}${entries.length ? `\n§\n${entries.join(ENTRY_DELIMITER)}\n` : ''}`, 'utf8')
461
+ }
462
+ } else if (choice === 'both') {
463
+ // keep both: local stays, remote re-stamped with new id
464
+ const newId = randomBytes(4).toString('hex')
465
+ const remoteRestamped = String(rec.remoteEntry).replace(/\[id:\s*[0-9a-f]{8}\]/i, `[id: ${newId}]`)
466
+ entries.push(remoteRestamped)
467
+ const p = projectTodoPath(root, cwd)
468
+ writeFileSync(p, `${TODO_HEADER}${entries.length ? `\n§\n${entries.join(ENTRY_DELIMITER)}\n` : ''}`, 'utf8')
469
+ }
470
+ } else {
471
+ const entries = readMemoryTrack(root, cwd, track)
472
+ const idLC = id.toLowerCase()
473
+ const localIdx = entries.findIndex((e) => (extractId(e) ?? '').toLowerCase() === idLC)
474
+ if (choice === 'local') {
475
+ // keep local unchanged
476
+ } else if (choice === 'remote') {
477
+ if (localIdx !== -1) entries[localIdx] = rec.remoteEntry
478
+ else entries.push(rec.remoteEntry)
479
+ writeMemoryTrack(root, cwd, track, entries)
480
+ } else if (choice === 'both') {
481
+ const newId = randomBytes(4).toString('hex')
482
+ const remoteRestamped = String(rec.remoteEntry).replace(/\[id:\s*[0-9a-f]{8}\]/i, `[id: ${newId}]`)
483
+ // keep local, add restamped remote
484
+ entries.push(remoteRestamped)
485
+ writeMemoryTrack(root, cwd, track, entries)
486
+ }
487
+ }
488
+
489
+ // remove resolved conflict
490
+ conflicts.splice(idx, 1)
491
+ this.writeConflicts(cwd, conflicts)
492
+ return { ok: true }
493
+ }
494
+ }