@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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +448 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +756 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +627 -0
- package/lib/index.js.map +1 -0
- package/lib/memory/batch.d.ts +44 -0
- package/lib/memory/batch.d.ts.map +1 -0
- package/lib/memory/batch.js +75 -0
- package/lib/memory/batch.js.map +1 -0
- package/lib/memory/feedback.d.ts +18 -0
- package/lib/memory/feedback.d.ts.map +1 -0
- package/lib/memory/feedback.js +29 -0
- package/lib/memory/feedback.js.map +1 -0
- package/lib/memory/store.d.ts +154 -0
- package/lib/memory/store.d.ts.map +1 -0
- package/lib/memory/store.js +606 -0
- package/lib/memory/store.js.map +1 -0
- package/lib/migration/cli.d.ts +18 -0
- package/lib/migration/cli.d.ts.map +1 -0
- package/lib/migration/cli.js +143 -0
- package/lib/migration/cli.js.map +1 -0
- package/lib/migration/fixture.d.ts +45 -0
- package/lib/migration/fixture.d.ts.map +1 -0
- package/lib/migration/fixture.js +177 -0
- package/lib/migration/fixture.js.map +1 -0
- package/lib/migration/service.d.ts +75 -0
- package/lib/migration/service.d.ts.map +1 -0
- package/lib/migration/service.js +713 -0
- package/lib/migration/service.js.map +1 -0
- package/lib/prompt/snapshot.d.ts +26 -0
- package/lib/prompt/snapshot.d.ts.map +1 -0
- package/lib/prompt/snapshot.js +74 -0
- package/lib/prompt/snapshot.js.map +1 -0
- package/lib/review/queue.d.ts +72 -0
- package/lib/review/queue.d.ts.map +1 -0
- package/lib/review/queue.js +306 -0
- package/lib/review/queue.js.map +1 -0
- package/lib/skills-browser.d.ts +67 -0
- package/lib/skills-browser.d.ts.map +1 -0
- package/lib/skills-browser.js +185 -0
- package/lib/skills-browser.js.map +1 -0
- package/lib/storage/atomic-store.d.ts +211 -0
- package/lib/storage/atomic-store.d.ts.map +1 -0
- package/lib/storage/atomic-store.js +582 -0
- package/lib/storage/atomic-store.js.map +1 -0
- package/lib/storage/layout.d.ts +81 -0
- package/lib/storage/layout.d.ts.map +1 -0
- package/lib/storage/layout.js +162 -0
- package/lib/storage/layout.js.map +1 -0
- package/lib/storage/legacy-format.d.ts +104 -0
- package/lib/storage/legacy-format.d.ts.map +1 -0
- package/lib/storage/legacy-format.js +262 -0
- package/lib/storage/legacy-format.js.map +1 -0
- package/lib/sync/config.d.ts +22 -0
- package/lib/sync/config.d.ts.map +1 -0
- package/lib/sync/config.js +57 -0
- package/lib/sync/config.js.map +1 -0
- package/lib/sync/git.d.ts +97 -0
- package/lib/sync/git.d.ts.map +1 -0
- package/lib/sync/git.js +282 -0
- package/lib/sync/git.js.map +1 -0
- package/lib/sync/index.d.ts +6 -0
- package/lib/sync/index.d.ts.map +1 -0
- package/lib/sync/index.js +6 -0
- package/lib/sync/index.js.map +1 -0
- package/lib/sync/layout.d.ts +15 -0
- package/lib/sync/layout.d.ts.map +1 -0
- package/lib/sync/layout.js +33 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/merge.d.ts +42 -0
- package/lib/sync/merge.d.ts.map +1 -0
- package/lib/sync/merge.js +159 -0
- package/lib/sync/merge.js.map +1 -0
- package/lib/sync/service.d.ts +70 -0
- package/lib/sync/service.d.ts.map +1 -0
- package/lib/sync/service.js +508 -0
- package/lib/sync/service.js.map +1 -0
- package/lib/todo/store.d.ts +142 -0
- package/lib/todo/store.d.ts.map +1 -0
- package/lib/todo/store.js +452 -0
- package/lib/todo/store.js.map +1 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/client/index.tsx +994 -0
- package/src/host/index.ts +591 -0
- package/src/host/memory/batch.ts +107 -0
- package/src/host/memory/feedback.ts +36 -0
- package/src/host/memory/store.ts +650 -0
- package/src/host/migration/cli.ts +139 -0
- package/src/host/migration/fixture.ts +218 -0
- package/src/host/migration/service.ts +786 -0
- package/src/host/prompt/snapshot.ts +95 -0
- package/src/host/review/queue.ts +325 -0
- package/src/host/skills-browser.ts +191 -0
- package/src/host/storage/atomic-store.ts +605 -0
- package/src/host/storage/layout.ts +191 -0
- package/src/host/storage/legacy-format.ts +308 -0
- package/src/host/sync/config.ts +67 -0
- package/src/host/sync/git.ts +280 -0
- package/src/host/sync/index.ts +5 -0
- package/src/host/sync/layout.ts +39 -0
- package/src/host/sync/merge.ts +188 -0
- package/src/host/sync/service.ts +494 -0
- 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
|
+
}
|