@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,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git.ts — Git adapter abstraction. Disabled => zero spawn.
|
|
3
|
+
* Real adapter uses child_process git with file:// remotes via --git-dir show.
|
|
4
|
+
* Mock adapter is in-memory for tests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { randomUUID } from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
export interface GitAdapter {
|
|
14
|
+
/** Fetch remote branch, return ok/error */
|
|
15
|
+
fetch(remoteUrl: string, branch: string): Promise<{ ok: true } | { ok: false; error: string }>
|
|
16
|
+
/** Push files to remote branch (commit & push). files keys are branch-root paths e.g. KEY.md */
|
|
17
|
+
push(remoteUrl: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }>
|
|
18
|
+
/** Get remote branch files (read). */
|
|
19
|
+
getRemoteFiles(remoteUrl: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* MockGitAdapter — in-memory remote, tracks calls for disabled assertions.
|
|
24
|
+
*/
|
|
25
|
+
export class MockGitAdapter implements GitAdapter {
|
|
26
|
+
// remoteUrl -> branch -> files
|
|
27
|
+
public remotes = new Map<string, Map<string, Record<string, string>>>()
|
|
28
|
+
public calls: Array<{ method: string; remoteUrl: string; branch: string }> = []
|
|
29
|
+
public failNextFetch: string | null = null
|
|
30
|
+
public failNextPush: string | null = null
|
|
31
|
+
public failNextGet: string | null = null
|
|
32
|
+
|
|
33
|
+
async fetch(remoteUrl: string, branch: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
34
|
+
this.calls.push({ method: 'fetch', remoteUrl, branch })
|
|
35
|
+
if (this.failNextFetch) {
|
|
36
|
+
const e = this.failNextFetch
|
|
37
|
+
this.failNextFetch = null
|
|
38
|
+
return { ok: false, error: e }
|
|
39
|
+
}
|
|
40
|
+
return { ok: true }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async push(remoteUrl: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
44
|
+
this.calls.push({ method: 'push', remoteUrl, branch })
|
|
45
|
+
if (this.failNextPush) {
|
|
46
|
+
const e = this.failNextPush
|
|
47
|
+
this.failNextPush = null
|
|
48
|
+
return { ok: false, error: e }
|
|
49
|
+
}
|
|
50
|
+
let br = this.remotes.get(remoteUrl)
|
|
51
|
+
if (!br) {
|
|
52
|
+
br = new Map()
|
|
53
|
+
this.remotes.set(remoteUrl, br)
|
|
54
|
+
}
|
|
55
|
+
br.set(branch, { ...files, '.meta.json': JSON.stringify({ message, at: new Date().toISOString() }) })
|
|
56
|
+
return { ok: true }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async getRemoteFiles(remoteUrl: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }> {
|
|
60
|
+
this.calls.push({ method: 'getRemoteFiles', remoteUrl, branch })
|
|
61
|
+
if (this.failNextGet) {
|
|
62
|
+
const e = this.failNextGet
|
|
63
|
+
this.failNextGet = null
|
|
64
|
+
return { ok: false, error: e }
|
|
65
|
+
}
|
|
66
|
+
const br = this.remotes.get(remoteUrl)?.get(branch)
|
|
67
|
+
if (!br) return { ok: true, files: {} }
|
|
68
|
+
// strip meta
|
|
69
|
+
const { '.meta.json': _m, ...files } = br as any
|
|
70
|
+
return { ok: true, files }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// helper to directly set remote files (simulate other machine push)
|
|
74
|
+
setRemoteFiles(remoteUrl: string, branch: string, files: Record<string, string>) {
|
|
75
|
+
let br = this.remotes.get(remoteUrl)
|
|
76
|
+
if (!br) {
|
|
77
|
+
br = new Map()
|
|
78
|
+
this.remotes.set(remoteUrl, br)
|
|
79
|
+
}
|
|
80
|
+
br.set(branch, { ...files })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getRemoteFilesSync(remoteUrl: string, branch: string): Record<string, string> | undefined {
|
|
84
|
+
return this.remotes.get(remoteUrl)?.get(branch)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
reset() {
|
|
88
|
+
this.calls = []
|
|
89
|
+
this.failNextFetch = null
|
|
90
|
+
this.failNextPush = null
|
|
91
|
+
this.failNextGet = null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* RealGitAdapter — uses git CLI for file:// remotes.
|
|
97
|
+
* For http/ssh remotes it delegates to git fetch/push via temp clone.
|
|
98
|
+
* Minimal implementation sufficient for integration tests with file:// bare remotes.
|
|
99
|
+
*/
|
|
100
|
+
export class RealGitAdapter implements GitAdapter {
|
|
101
|
+
async fetch(remoteUrl: string, branch: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
102
|
+
// For file:// bare remote, check existence via ls-remote; no real fetch needed.
|
|
103
|
+
// We treat fetch as ls-remote to validate connectivity.
|
|
104
|
+
const res = spawnSync('git', ['ls-remote', remoteUrl, `refs/heads/${branch}`], { encoding: 'utf8', timeout: 5000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
|
|
105
|
+
if (res.error) return { ok: false, error: (res.error as any).message ?? String(res.error) }
|
|
106
|
+
if (res.status !== 0) {
|
|
107
|
+
const stderr = String(res.stderr ?? '')
|
|
108
|
+
const lower = stderr.toLowerCase()
|
|
109
|
+
// Only a reachable repo where this exact branch/ref does not exist yet is
|
|
110
|
+
// acceptable (means "first push"). Everything else — unreachable remote,
|
|
111
|
+
// not a repo, or an auth/connectivity failure — is a hard error so we
|
|
112
|
+
// never silently treat an offline remote as "no data".
|
|
113
|
+
if (lower.includes("couldn't find remote ref") || lower.includes('no such ref') || lower.includes('unknown revision')) {
|
|
114
|
+
return { ok: true }
|
|
115
|
+
}
|
|
116
|
+
return { ok: false, error: stderr.trim() || `ls-remote failed (status ${res.status})` }
|
|
117
|
+
}
|
|
118
|
+
return { ok: true }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async getRemoteFiles(remoteUrl: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }> {
|
|
122
|
+
// Use git --git-dir=<remote> show branch:file if remote is file:// path
|
|
123
|
+
const filePath = this.resolveFileUrl(remoteUrl)
|
|
124
|
+
if (filePath) {
|
|
125
|
+
// file:// bare remote
|
|
126
|
+
return this.getFilesFromBare(filePath, branch)
|
|
127
|
+
}
|
|
128
|
+
// For non-file remotes, clone to temp and read
|
|
129
|
+
return this.getFilesViaClone(remoteUrl, branch)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async push(remoteUrl: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
133
|
+
const filePath = this.resolveFileUrl(remoteUrl)
|
|
134
|
+
if (filePath) {
|
|
135
|
+
return this.pushToBare(filePath, branch, files, message)
|
|
136
|
+
}
|
|
137
|
+
return this.pushViaClone(remoteUrl, branch, files, message)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private resolveFileUrl(url: string): string | null {
|
|
141
|
+
if (url.startsWith('file://')) return url.slice('file://'.length)
|
|
142
|
+
if (url.startsWith('/tmp/') || url.startsWith('/')) {
|
|
143
|
+
// Treat absolute path as file remote for tests (implicit file://)
|
|
144
|
+
if (existsSync(url)) return url
|
|
145
|
+
// also allow /tmp/xxx without existence check
|
|
146
|
+
if (url.startsWith('/tmp/')) return url
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private getFilesFromBare(barePath: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }> {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
const expected = ['KEY.md', 'MEMORY.md', 'KEY-archive.md', 'TODOS.md']
|
|
154
|
+
const files: Record<string, string> = {}
|
|
155
|
+
for (const f of expected) {
|
|
156
|
+
const res = spawnSync('git', ['--git-dir', barePath, 'show', `${branch}:${f}`], { encoding: 'utf8', timeout: 5000 })
|
|
157
|
+
if (res.status === 0) {
|
|
158
|
+
files[f] = res.stdout
|
|
159
|
+
} else {
|
|
160
|
+
// file not on branch or branch missing -> treat as missing
|
|
161
|
+
// if branch missing, files empty
|
|
162
|
+
const stderr = String(res.stderr ?? '')
|
|
163
|
+
if (stderr.includes('not found') || stderr.includes('does not exist') || stderr.includes('invalid object')) {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
// other error -> treat as empty for now
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Check if branch exists at all: if no files and branch missing, return empty (not error)
|
|
171
|
+
const ls = spawnSync('git', ['--git-dir', barePath, 'rev-parse', '--verify', `refs/heads/${branch}`], { encoding: 'utf8' })
|
|
172
|
+
if (ls.status !== 0 && Object.keys(files).length === 0) {
|
|
173
|
+
// branch not existent yet
|
|
174
|
+
resolve({ ok: true, files: {} })
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
resolve({ ok: true, files })
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private pushToBare(barePath: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
const tmp = mkdtempSync(join(tmpdir(), 'maestro-sync-'))
|
|
184
|
+
try {
|
|
185
|
+
// init temp repo
|
|
186
|
+
let r = spawnSync('git', ['init'], { cwd: tmp, encoding: 'utf8' })
|
|
187
|
+
if (r.status !== 0) return resolve({ ok: false, error: r.stderr?.toString() ?? 'init failed' })
|
|
188
|
+
r = spawnSync('git', ['remote', 'add', 'origin', barePath], { cwd: tmp, encoding: 'utf8' })
|
|
189
|
+
if (r.status !== 0) return resolve({ ok: false, error: r.stderr?.toString() ?? 'remote add failed' })
|
|
190
|
+
// try fetch existing branch
|
|
191
|
+
spawnSync('git', ['fetch', 'origin', branch], { cwd: tmp, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
|
|
192
|
+
// Check out the branch ON TOP of the fetched remote tip (FETCH_HEAD) so a
|
|
193
|
+
// subsequent commit is a descendant of the remote history and can be
|
|
194
|
+
// pushed as a fast-forward. On the first push FETCH_HEAD is absent, so
|
|
195
|
+
// checkout falls through to the orphan branch path below.
|
|
196
|
+
const checkout = spawnSync('git', ['checkout', '-B', branch, 'FETCH_HEAD'], { cwd: tmp, encoding: 'utf8' })
|
|
197
|
+
if (checkout.status !== 0) {
|
|
198
|
+
// try orphan
|
|
199
|
+
spawnSync('git', ['checkout', '--orphan', branch], { cwd: tmp, encoding: 'utf8' })
|
|
200
|
+
spawnSync('git', ['rm', '-rf', '.'], { cwd: tmp, encoding: 'utf8' })
|
|
201
|
+
}
|
|
202
|
+
// write files
|
|
203
|
+
for (const [name, content] of Object.entries(files)) {
|
|
204
|
+
const p = join(tmp, name)
|
|
205
|
+
mkdirSync(join(p, '..'), { recursive: true })
|
|
206
|
+
writeFileSync(p, content, 'utf8')
|
|
207
|
+
}
|
|
208
|
+
spawnSync('git', ['add', '.'], { cwd: tmp, encoding: 'utf8' })
|
|
209
|
+
// need user identity
|
|
210
|
+
const env = { ...process.env, GIT_AUTHOR_NAME: 'maestro', GIT_AUTHOR_EMAIL: 'maestro@local', GIT_COMMITTER_NAME: 'maestro', GIT_COMMITTER_EMAIL: 'maestro@local' }
|
|
211
|
+
const commit = spawnSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: tmp, encoding: 'utf8', env })
|
|
212
|
+
if (commit.status !== 0) return resolve({ ok: false, error: commit.stderr?.toString() ?? 'commit failed' })
|
|
213
|
+
const push = spawnSync('git', ['push', 'origin', `${branch}:${branch}`], { cwd: tmp, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
|
|
214
|
+
if (push.status !== 0) return resolve({ ok: false, error: push.stderr?.toString() ?? 'push failed' })
|
|
215
|
+
resolve({ ok: true })
|
|
216
|
+
} finally {
|
|
217
|
+
try { rmSync(tmp, { recursive: true, force: true }) } catch {}
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private getFilesViaClone(remoteUrl: string, branch: string): Promise<{ ok: true; files: Record<string, string> } | { ok: false; error: string }> {
|
|
223
|
+
return new Promise((resolve) => {
|
|
224
|
+
const tmp = mkdtempSync(join(tmpdir(), 'maestro-sync-clone-'))
|
|
225
|
+
try {
|
|
226
|
+
const clone = spawnSync('git', ['clone', '--branch', branch, '--single-branch', remoteUrl, tmp + '/repo'], { encoding: 'utf8', timeout: 10000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
|
|
227
|
+
if (clone.status !== 0) {
|
|
228
|
+
// branch may not exist yet
|
|
229
|
+
resolve({ ok: true, files: {} })
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
const repo = join(tmp, 'repo')
|
|
233
|
+
const files: Record<string, string> = {}
|
|
234
|
+
for (const f of ['KEY.md', 'MEMORY.md', 'KEY-archive.md', 'TODOS.md']) {
|
|
235
|
+
const p = join(repo, f)
|
|
236
|
+
if (existsSync(p)) files[f] = readFileSync(p, 'utf8')
|
|
237
|
+
}
|
|
238
|
+
resolve({ ok: true, files })
|
|
239
|
+
} finally {
|
|
240
|
+
try { rmSync(tmp, { recursive: true, force: true }) } catch {}
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private pushViaClone(remoteUrl: string, branch: string, files: Record<string, string>, message: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
const tmp = mkdtempSync(join(tmpdir(), 'maestro-sync-push-'))
|
|
248
|
+
try {
|
|
249
|
+
// shallow clone or init
|
|
250
|
+
let repo = join(tmp, 'repo')
|
|
251
|
+
mkdirSync(repo, { recursive: true })
|
|
252
|
+
let r = spawnSync('git', ['init'], { cwd: repo, encoding: 'utf8' })
|
|
253
|
+
if (r.status !== 0) return resolve({ ok: false, error: r.stderr?.toString() ?? 'init failed' })
|
|
254
|
+
r = spawnSync('git', ['remote', 'add', 'origin', remoteUrl], { cwd: repo, encoding: 'utf8' })
|
|
255
|
+
if (r.status !== 0) return resolve({ ok: false, error: r.stderr?.toString() ?? 'remote add failed' })
|
|
256
|
+
spawnSync('git', ['fetch', 'origin', branch], { cwd: repo, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
|
|
257
|
+
// Base the branch on the remote tip (FETCH_HEAD) so we fast-forward
|
|
258
|
+
// instead of producing an orphan root commit that the second push
|
|
259
|
+
// rejects as non-fast-forward.
|
|
260
|
+
const checkout = spawnSync('git', ['checkout', '-B', branch, 'FETCH_HEAD'], { cwd: repo, encoding: 'utf8' })
|
|
261
|
+
if (checkout.status !== 0) {
|
|
262
|
+
spawnSync('git', ['checkout', '--orphan', branch], { cwd: repo, encoding: 'utf8' })
|
|
263
|
+
spawnSync('git', ['rm', '-rf', '.'], { cwd: repo, encoding: 'utf8' })
|
|
264
|
+
}
|
|
265
|
+
for (const [name, content] of Object.entries(files)) {
|
|
266
|
+
writeFileSync(join(repo, name), content, 'utf8')
|
|
267
|
+
}
|
|
268
|
+
spawnSync('git', ['add', '.'], { cwd: repo, encoding: 'utf8' })
|
|
269
|
+
const env = { ...process.env, GIT_AUTHOR_NAME: 'maestro', GIT_AUTHOR_EMAIL: 'maestro@local', GIT_COMMITTER_NAME: 'maestro', GIT_COMMITTER_EMAIL: 'maestro@local' }
|
|
270
|
+
const commit = spawnSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: repo, encoding: 'utf8', env })
|
|
271
|
+
if (commit.status !== 0) return resolve({ ok: false, error: commit.stderr?.toString() ?? 'commit failed' })
|
|
272
|
+
const push = spawnSync('git', ['push', 'origin', `${branch}:${branch}`], { cwd: repo, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
|
|
273
|
+
if (push.status !== 0) return resolve({ ok: false, error: push.stderr?.toString() ?? 'push failed' })
|
|
274
|
+
resolve({ ok: true })
|
|
275
|
+
} finally {
|
|
276
|
+
try { rmSync(tmp, { recursive: true, force: true }) } catch {}
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { maestroMetaDir, projectHash } from '../storage/layout.ts'
|
|
3
|
+
|
|
4
|
+
export function syncDir(root: string, hash: string): string {
|
|
5
|
+
return join(maestroMetaDir(root), 'sync', hash)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function syncConfigPath(root: string, hash: string): string {
|
|
9
|
+
return join(syncDir(root, hash), 'config.json')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function syncMetaPath(root: string, hash: string): string {
|
|
13
|
+
return join(syncDir(root, hash), 'lastSync.json')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function syncConflictsPath(root: string, hash: string): string {
|
|
17
|
+
return join(syncDir(root, hash), 'conflicts.jsonl')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function syncBranchName(hash: string): string {
|
|
21
|
+
return `maestro-memory/${hash}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveSyncHash(cwd: string): string {
|
|
25
|
+
return projectHash(cwd)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// aggregated for tests
|
|
29
|
+
export function allSyncPaths(root: string, cwd: string) {
|
|
30
|
+
const hash = projectHash(cwd)
|
|
31
|
+
return {
|
|
32
|
+
dir: syncDir(root, hash),
|
|
33
|
+
config: syncConfigPath(root, hash),
|
|
34
|
+
meta: syncMetaPath(root, hash),
|
|
35
|
+
conflicts: syncConflictsPath(root, hash),
|
|
36
|
+
branch: syncBranchName(hash),
|
|
37
|
+
hash,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge.ts — union merge by id, never drops either version.
|
|
3
|
+
* Pure functions, no I/O.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MEMORY_ID_RE = /^\[id:\s*([0-9a-f]{8})\]\s*/i
|
|
7
|
+
|
|
8
|
+
export interface ConflictRecord {
|
|
9
|
+
id: string
|
|
10
|
+
track: string
|
|
11
|
+
localEntry: string | null
|
|
12
|
+
remoteEntry: string | null
|
|
13
|
+
reason: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MergeResult {
|
|
17
|
+
merged: string[]
|
|
18
|
+
conflicts: ConflictRecord[]
|
|
19
|
+
addedLocal: string[]
|
|
20
|
+
addedRemote: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extractMemoryId(entry: string): string | null {
|
|
24
|
+
const m = MEMORY_ID_RE.exec(String(entry))
|
|
25
|
+
return m ? m[1].toLowerCase() : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeEntry(entry: string): string {
|
|
29
|
+
return String(entry).trim()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function contentHash(entry: string): string {
|
|
33
|
+
// strip id prefix for comparison of body? But for modifiedBoth we want body difference
|
|
34
|
+
// Compare full stripped id content; if id same but body differs => conflict
|
|
35
|
+
const withoutId = String(entry).replace(MEMORY_ID_RE, '').trim()
|
|
36
|
+
return withoutId
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse entries into map id => entry. Entries without id are assigned a synthetic
|
|
41
|
+
* id derived from content hash (for safety) but flagged as missing.
|
|
42
|
+
*/
|
|
43
|
+
export function mapById(entries: string[]): Map<string, string> {
|
|
44
|
+
const m = new Map<string, string>()
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
const id = extractMemoryId(e)
|
|
47
|
+
if (id) {
|
|
48
|
+
// keep first occurrence; duplicates by id should not happen but last wins
|
|
49
|
+
if (!m.has(id)) m.set(id, e)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return m
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function idsOf(entries: string[]): Set<string> {
|
|
56
|
+
const s = new Set<string>()
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const id = extractMemoryId(e)
|
|
59
|
+
if (id) s.add(id)
|
|
60
|
+
}
|
|
61
|
+
return s
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Union merge for memory-style entries (KEY, MEMORY, archive).
|
|
66
|
+
* - local + remote entries with distinct ids => union (both present)
|
|
67
|
+
* - same id, same content => one entry
|
|
68
|
+
* - same id, different content => conflict (neither applied, keep local)
|
|
69
|
+
*/
|
|
70
|
+
export function mergeMemoryEntries(opts: {
|
|
71
|
+
track: string
|
|
72
|
+
local: string[]
|
|
73
|
+
remote: string[]
|
|
74
|
+
baseIds?: Set<string>
|
|
75
|
+
}): MergeResult {
|
|
76
|
+
const { track, local, remote, baseIds } = opts
|
|
77
|
+
const localMap = mapById(local)
|
|
78
|
+
const remoteMap = mapById(remote)
|
|
79
|
+
|
|
80
|
+
const base = baseIds ?? new Set<string>()
|
|
81
|
+
|
|
82
|
+
const localIds = new Set(localMap.keys())
|
|
83
|
+
const remoteIds = new Set(remoteMap.keys())
|
|
84
|
+
|
|
85
|
+
const allIds = new Set<string>([...localIds, ...remoteIds])
|
|
86
|
+
|
|
87
|
+
const merged: string[] = []
|
|
88
|
+
const conflicts: ConflictRecord[] = []
|
|
89
|
+
const addedLocal: string[] = []
|
|
90
|
+
const addedRemote: string[] = []
|
|
91
|
+
|
|
92
|
+
for (const id of allIds) {
|
|
93
|
+
const hasLocal = localMap.has(id)
|
|
94
|
+
const hasRemote = remoteMap.has(id)
|
|
95
|
+
const localEntry = localMap.get(id) ?? null
|
|
96
|
+
const remoteEntry = remoteMap.get(id) ?? null
|
|
97
|
+
|
|
98
|
+
if (hasLocal && !hasRemote) {
|
|
99
|
+
// local only
|
|
100
|
+
if (base.has(id)) {
|
|
101
|
+
// deletedRemote? base had it, remote deleted -> conflict if local modified?
|
|
102
|
+
// per spec: delete vs modify => conflict, delete vs delete => absent
|
|
103
|
+
// Our simple rule: if base has id and remote missing, but local present and content differs from base? we don't have base content, so treat as remote deletion -> keep local but flag? For MVP, keep local.
|
|
104
|
+
merged.push(localEntry!)
|
|
105
|
+
} else {
|
|
106
|
+
// addedLocal
|
|
107
|
+
addedLocal.push(id)
|
|
108
|
+
merged.push(localEntry!)
|
|
109
|
+
}
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
if (!hasLocal && hasRemote) {
|
|
113
|
+
if (base.has(id)) {
|
|
114
|
+
// deletedLocal vs remote present => conflict? keep remote? per spec conflict
|
|
115
|
+
// For MVP, we consider this addedRemote if not in base, else conflict -> keep remote but record conflict? Simpler add.
|
|
116
|
+
if (localIds.has(id)) {
|
|
117
|
+
// unreachable
|
|
118
|
+
}
|
|
119
|
+
// If base had it and local deleted -> conflict not auto delete. We treat as conflict.
|
|
120
|
+
// Check if local deleted intentionally: we flag conflict
|
|
121
|
+
conflicts.push({ id, track, localEntry: null, remoteEntry, reason: 'deletedLocal vs modifiedRemote' })
|
|
122
|
+
// do not auto-merge; keep local deletion (i.e., not in merged) — but per never-drop, we should keep remote as conflict.
|
|
123
|
+
// For now, push remote to merged but also mark conflict, so union still contains remote? Spec says delete not applied when other side modified => conflict.
|
|
124
|
+
// We'll keep remote out of merged until resolve, to avoid auto-drop of delete.
|
|
125
|
+
// Decision: do not push to merged, leave conflict.
|
|
126
|
+
continue
|
|
127
|
+
} else {
|
|
128
|
+
addedRemote.push(id)
|
|
129
|
+
merged.push(remoteEntry!)
|
|
130
|
+
}
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (hasLocal && hasRemote) {
|
|
134
|
+
const l = localEntry!
|
|
135
|
+
const r = remoteEntry!
|
|
136
|
+
if (normalizeEntry(l) === normalizeEntry(r)) {
|
|
137
|
+
merged.push(l)
|
|
138
|
+
} else if (contentHash(l) === contentHash(r)) {
|
|
139
|
+
// ids same, content same after stripping id? Actually already compared normalized; if equal after stripping id they are same body
|
|
140
|
+
merged.push(l)
|
|
141
|
+
} else {
|
|
142
|
+
// same id, different body => conflict
|
|
143
|
+
conflicts.push({ id, track, localEntry: l, remoteEntry: r, reason: 'modifiedBoth' })
|
|
144
|
+
// keep local in merged, remote not auto-applied
|
|
145
|
+
merged.push(l)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Also handle local entries without id (legacy) — they are not in map; append them verbatim to merged if not duplicated
|
|
151
|
+
for (const e of local) {
|
|
152
|
+
if (!extractMemoryId(e)) {
|
|
153
|
+
if (!merged.includes(e)) merged.push(e)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const e of remote) {
|
|
157
|
+
if (!extractMemoryId(e) && !merged.includes(e)) {
|
|
158
|
+
// remote entry without id — treat as addedRemote but can't dedupe; add if not duplicate body
|
|
159
|
+
const body = contentHash(e)
|
|
160
|
+
const exists = merged.some((m) => contentHash(m) === body)
|
|
161
|
+
if (!exists) {
|
|
162
|
+
merged.push(e)
|
|
163
|
+
if (!extractMemoryId(e)) {
|
|
164
|
+
// synthetic id absent, still count as added
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { merged, conflicts, addedLocal, addedRemote }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Todo merge: same id logic but todos are already stamped with id; reuse same function with track='TODOS'
|
|
174
|
+
export function mergeTodoEntries(opts: { local: string[]; remote: string[]; baseIds?: Set<string> }): MergeResult {
|
|
175
|
+
// Todos use same id regex: \[id: xxxxxxxx\]
|
|
176
|
+
// But todo entries are multiline: first line contains id; our extract works on full entry (first line)
|
|
177
|
+
return mergeMemoryEntries({ track: 'TODOS', local: opts.local, remote: opts.remote, baseIds: opts.baseIds })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Helpers for sync service: compute ids snapshot for meta
|
|
181
|
+
export function snapshotIds(entriesByTrack: Record<string, string[]>): Record<string, string[]> {
|
|
182
|
+
const out: Record<string, string[]> = {}
|
|
183
|
+
for (const [track, entries] of Object.entries(entriesByTrack)) {
|
|
184
|
+
const ids = [...idsOf(entries)].sort()
|
|
185
|
+
out[track] = ids
|
|
186
|
+
}
|
|
187
|
+
return out
|
|
188
|
+
}
|