@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,786 @@
1
+ /**
2
+ * migration/service.ts — inspect/dryRun/run/verify, backup manifest, journal, write-block on mismatch
3
+ *
4
+ * Implements M3-PR-B migration engine: in-place adoption, byte-preserving backup,
5
+ * SHA-256 verified, parser inventory, malformed JSONL tolerant, missing optional files tolerant,
6
+ * noncanonical/lock warnings, and write-block on verify mismatch.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto'
10
+ import {
11
+ existsSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ statSync,
15
+ mkdirSync,
16
+ writeFileSync,
17
+ appendFileSync,
18
+ copyFileSync,
19
+ rmSync,
20
+ unlinkSync,
21
+ } from 'node:fs'
22
+ import { join, relative, dirname } from 'node:path'
23
+ import {
24
+ resolveMemoryRoot,
25
+ maestroMetaDir,
26
+ schemaPath,
27
+ journalPath,
28
+ backupsDir,
29
+ backupManifestPath,
30
+ backupFilesDirPath,
31
+ } from '../storage/layout.ts'
32
+ import { parseEntries, isCanonical } from '../storage/legacy-format.ts'
33
+ import { parseTodoEntry } from '../storage/legacy-format.ts'
34
+
35
+ function sha256Hex(buf: Buffer): string {
36
+ return createHash('sha256').update(buf).digest('hex')
37
+ }
38
+
39
+ function readBytesAndHash(filePath: string): { bytes: number; sha256: string; raw: Buffer } {
40
+ const raw = readFileSync(filePath)
41
+ return { bytes: raw.length, sha256: sha256Hex(raw), raw }
42
+ }
43
+
44
+ function listDailyFiles(root: string): string[] {
45
+ const dailyRoot = join(root, 'daily')
46
+ try {
47
+ const names = readdirSync(dailyRoot)
48
+ return names.filter((n) => n.endsWith('.md') || n.endsWith('.todo.md')).map((n) => join(dailyRoot, n))
49
+ } catch {
50
+ return []
51
+ }
52
+ }
53
+
54
+ function listProjectFiles(root: string): string[] {
55
+ const projRoot = join(root, 'projects')
56
+ try {
57
+ const hashes = readdirSync(projRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
58
+ const out: string[] = []
59
+ for (const h of hashes) {
60
+ const dir = join(projRoot, h)
61
+ for (const name of ['MEMORY.md', 'KEY.md', 'KEY-archive.md', 'TODOS.md']) {
62
+ const p = join(dir, name)
63
+ out.push(p) // push even if missing; caller will check exists
64
+ }
65
+ }
66
+ return out
67
+ } catch {
68
+ return []
69
+ }
70
+ }
71
+
72
+ function collectExpectedPaths(root: string): string[] {
73
+ const expected: string[] = [
74
+ join(root, 'MEMORY.md'),
75
+ join(root, 'USER.md'),
76
+ join(root, 'MEMORY-archive.md'),
77
+ join(root, 'USER-archive.md'),
78
+ join(root, 'SUGGESTIONS.jsonl'),
79
+ join(root, 'TODOS-life.md'),
80
+ join(root, 'TODOS-work.md'),
81
+ join(root, 'TODO-archive.md'),
82
+ ]
83
+ expected.push(...listDailyFiles(root))
84
+ expected.push(...listProjectFiles(root))
85
+ // also include any daily files that may exist but we already added; ensure uniqueness
86
+ // Also include any extra files under root for backup completeness: scan all files recursively excluding .maestro-memory
87
+ // But for inspect we want to list expected + known daily/project; extra files will be discovered via full scan for backup
88
+ return expected
89
+ }
90
+
91
+ function scanAllForBackup(root: string): string[] {
92
+ const out: string[] = []
93
+ function walk(dir: string): void {
94
+ let entries: any[]
95
+ try {
96
+ entries = readdirSync(dir, { withFileTypes: true })
97
+ } catch {
98
+ return
99
+ }
100
+ for (const e of entries) {
101
+ const p = join(dir, e.name)
102
+ if (p.includes('.maestro-memory')) continue
103
+ if (e.isDirectory()) {
104
+ // skip .maestro-memory at any depth
105
+ if (e.name === '.maestro-memory') continue
106
+ walk(p)
107
+ } else if (e.isFile()) {
108
+ out.push(p)
109
+ }
110
+ }
111
+ }
112
+ walk(root)
113
+ return out
114
+ }
115
+
116
+ function parseMemoryEntriesSafe(text: string): { entries: string[]; isCanonical: boolean } {
117
+ const entries = parseEntries(text)
118
+ const canon = text.trim() === '' || isCanonical(text)
119
+ return { entries, isCanonical: canon }
120
+ }
121
+
122
+ function parseTodoFileSafe(filePath: string): { count: number; ids: string[]; valid: number; malformed: number } {
123
+ let raw: string
124
+ try {
125
+ raw = readFileSync(filePath, 'utf8')
126
+ } catch {
127
+ return { count: 0, ids: [], valid: 0, malformed: 0 }
128
+ }
129
+ // strip header comment
130
+ const body = raw.replace(/^<!--[\s\S]*?-->\s*/, '').replace(/^\s*§\s*\n?/, '').trim()
131
+ if (body === '') return { count: 0, ids: [], valid: 0, malformed: 0 }
132
+ const parts = body.split('\n§\n').map((s) => s.trim()).filter(Boolean)
133
+ const ids: string[] = []
134
+ let valid = 0
135
+ for (const p of parts) {
136
+ const parsed = parseTodoEntry(p)
137
+ if (parsed && parsed.id) {
138
+ valid += 1
139
+ ids.push(parsed.id)
140
+ }
141
+ }
142
+ return { count: parts.length, ids, valid, malformed: parts.length - valid }
143
+ }
144
+
145
+ function parseQueueSafe(filePath: string): { valid: number; malformed: number; entries: any[] } {
146
+ try {
147
+ const text = readFileSync(filePath, 'utf8')
148
+ const lines = text.split('\n')
149
+ let valid = 0
150
+ let malformed = 0
151
+ const entries: any[] = []
152
+ for (const line of lines) {
153
+ if (line.trim() === '') continue
154
+ try {
155
+ const obj = JSON.parse(line)
156
+ if (obj && typeof obj.target === 'string' && typeof obj.content === 'string') {
157
+ valid += 1
158
+ entries.push(obj)
159
+ } else {
160
+ malformed += 1
161
+ }
162
+ } catch {
163
+ malformed += 1
164
+ }
165
+ }
166
+ return { valid, malformed, entries }
167
+ } catch (e: any) {
168
+ if (e?.code === 'ENOENT') return { valid: 0, malformed: 0, entries: [] }
169
+ throw e
170
+ }
171
+ }
172
+
173
+ export interface FileInfo {
174
+ path: string
175
+ relative: string
176
+ exists: boolean
177
+ bytes: number
178
+ sha256: string | null
179
+ kind: string
180
+ entries?: number
181
+ ids?: string[]
182
+ malformedLines?: number
183
+ valid?: number
184
+ warning?: string | null
185
+ }
186
+
187
+ export interface InspectResult {
188
+ ok: boolean
189
+ root: string
190
+ files: FileInfo[]
191
+ inventory: {
192
+ memoryEntries: number
193
+ todoIds: string[]
194
+ todoIdsCount: number
195
+ todoCount: number
196
+ queueValid: number
197
+ queueMalformed: number
198
+ dailyFiles: number
199
+ projectDirs: number
200
+ } & Record<string, any>
201
+ warnings: string[]
202
+ errors: string[]
203
+ }
204
+
205
+ function kindFor(filePath: string, root: string): string {
206
+ const rel = relative(root, filePath)
207
+ if (rel === 'MEMORY.md' || rel === 'USER.md') return 'memory'
208
+ if (rel.endsWith('-archive.md')) return 'archive'
209
+ if (rel.endsWith('SUGGESTIONS.jsonl')) return 'queue'
210
+ if (rel.startsWith('daily/') && rel.endsWith('.todo.md')) return 'todo-daily'
211
+ if (rel.startsWith('daily/')) return 'daily'
212
+ if (rel.includes('projects/') && rel.endsWith('TODOS.md')) return 'todo-project'
213
+ if (rel.includes('projects/') && rel.endsWith('KEY.md')) return 'key'
214
+ if (rel.includes('projects/') && rel.endsWith('KEY-archive.md')) return 'archive'
215
+ if (rel.includes('projects/') && rel.endsWith('MEMORY.md')) return 'project'
216
+ if (rel.startsWith('TODOS-') || rel === 'TODOS-life.md' || rel === 'TODOS-work.md') return 'todo'
217
+ if (rel === 'TODO-archive.md') return 'archive'
218
+ return 'other'
219
+ }
220
+
221
+ export async function inspect(memoryDir: string | null | undefined = null): Promise<InspectResult> {
222
+ const root = resolveMemoryRoot(memoryDir as any)
223
+ const warnings: string[] = []
224
+ const errors: string[] = []
225
+ const files: FileInfo[] = []
226
+
227
+ const expected = collectExpectedPaths(root)
228
+ // Use set to deduplicate expected
229
+ const seen = new Set<string>()
230
+ const expectedUnique = expected.filter((p) => {
231
+ if (seen.has(p)) return false
232
+ seen.add(p)
233
+ return true
234
+ })
235
+
236
+ let totalMemoryEntries = 0
237
+ let allTodoIds: string[] = []
238
+ let totalTodoCount = 0
239
+ let queueValid = 0
240
+ let queueMalformed = 0
241
+ let dailyFiles = 0
242
+ let projectDirs = 0
243
+
244
+ // Count project dirs
245
+ try {
246
+ const projRoot = join(root, 'projects')
247
+ const hashes = readdirSync(projRoot, { withFileTypes: true }).filter((d) => d.isDirectory())
248
+ projectDirs = hashes.length
249
+ } catch {
250
+ projectDirs = 0
251
+ }
252
+ try {
253
+ dailyFiles = readdirSync(join(root, 'daily')).filter((n) => n.endsWith('.md')).length
254
+ } catch {
255
+ dailyFiles = 0
256
+ }
257
+
258
+ for (const abs of expectedUnique) {
259
+ const rel = relative(root, abs)
260
+ const exists = existsSync(abs)
261
+ if (!exists) {
262
+ files.push({
263
+ path: abs,
264
+ relative: rel,
265
+ exists: false,
266
+ bytes: 0,
267
+ sha256: null,
268
+ kind: kindFor(abs, root),
269
+ warning: null,
270
+ })
271
+ continue
272
+ }
273
+ const { bytes, sha256, raw } = readBytesAndHash(abs)
274
+ const kind = kindFor(abs, root)
275
+ let entriesCount: number | undefined
276
+ let warning: string | null = null
277
+ let malformedLines: number | undefined
278
+ let valid: number | undefined
279
+ let ids: string[] | undefined
280
+
281
+ if (kind === 'memory' || kind === 'project' || kind === 'key' || kind === 'daily' || kind === 'archive') {
282
+ const text = raw.toString('utf8')
283
+ const { entries, isCanonical: canon } = parseMemoryEntriesSafe(text)
284
+ entriesCount = entries.length
285
+ totalMemoryEntries += entriesCount
286
+ if (!canon && text.trim() !== '') {
287
+ warning = `non-canonical content in ${rel} (drift)`
288
+ warnings.push(`non-canonical: ${rel}`)
289
+ }
290
+ } else if (kind === 'todo' || kind === 'todo-project' || kind === 'todo-daily') {
291
+ const info = parseTodoFileSafe(abs)
292
+ entriesCount = info.count
293
+ ids = info.ids
294
+ allTodoIds.push(...info.ids)
295
+ totalTodoCount += info.count
296
+ if (info.malformed > 0) {
297
+ warning = `malformed todo entries in ${rel}: ${info.malformed}`
298
+ warnings.push(`malformed todo: ${rel} (${info.malformed})`)
299
+ }
300
+ } else if (kind === 'queue') {
301
+ const q = parseQueueSafe(abs)
302
+ queueValid = q.valid
303
+ queueMalformed = q.malformed
304
+ entriesCount = q.valid + q.malformed
305
+ malformedLines = q.malformed
306
+ valid = q.valid
307
+ if (q.malformed > 0) {
308
+ warning = `malformed JSONL in ${rel}: ${q.malformed} lines`
309
+ warnings.push(`malformed JSONL: ${rel} (${q.malformed} malformed, ${q.valid} valid)`)
310
+ }
311
+ }
312
+
313
+ files.push({
314
+ path: abs,
315
+ relative: rel,
316
+ exists: true,
317
+ bytes,
318
+ sha256,
319
+ kind,
320
+ entries: entriesCount,
321
+ ids,
322
+ malformedLines,
323
+ valid,
324
+ warning,
325
+ })
326
+ }
327
+
328
+ // Also detect lock files
329
+ const lockPaths: string[] = []
330
+ function findLocks(dir: string): void {
331
+ try {
332
+ const entries = readdirSync(dir, { withFileTypes: true })
333
+ for (const e of entries) {
334
+ const p = join(dir, e.name)
335
+ if (e.isDirectory()) {
336
+ if (e.name === '.maestro-memory') continue
337
+ findLocks(p)
338
+ } else if (e.name === '.maestro.lock') {
339
+ lockPaths.push(p)
340
+ }
341
+ }
342
+ } catch {}
343
+ }
344
+ try {
345
+ findLocks(root)
346
+ } catch {}
347
+ for (const lp of lockPaths) {
348
+ warnings.push(`lock file present: ${relative(root, lp)}`)
349
+ }
350
+
351
+ // For backup completeness, also scan all files not in expected (extra legacy files like advisor/)
352
+ const allFiles = scanAllForBackup(root)
353
+ const expectedSet = new Set(expectedUnique)
354
+ for (const abs of allFiles) {
355
+ if (expectedSet.has(abs)) continue
356
+ const rel = relative(root, abs)
357
+ // already handled daily/project extra? but scan will catch additional files like advisor/
358
+ const { bytes, sha256 } = readBytesAndHash(abs)
359
+ files.push({
360
+ path: abs,
361
+ relative: rel,
362
+ exists: true,
363
+ bytes,
364
+ sha256,
365
+ kind: 'other',
366
+ warning: null,
367
+ })
368
+ }
369
+
370
+ // ok: inspect never blocks on missing optional or malformed; only errors would be unreadable?
371
+ const ok = errors.length === 0
372
+
373
+ return {
374
+ ok,
375
+ root,
376
+ files,
377
+ inventory: {
378
+ memoryEntries: totalMemoryEntries,
379
+ todoIds: allTodoIds,
380
+ todoIdsCount: allTodoIds.length,
381
+ todoCount: totalTodoCount,
382
+ queueValid,
383
+ queueMalformed,
384
+ dailyFiles,
385
+ projectDirs,
386
+ },
387
+ warnings,
388
+ errors,
389
+ }
390
+ }
391
+
392
+ export async function dryRun(memoryDir: string | null | undefined = null): Promise<InspectResult> {
393
+ // dryRun is same as inspect but explicitly read-only; ensure no side effects
394
+ return inspect(memoryDir)
395
+ }
396
+
397
+ function utcRunId(): string {
398
+ const now = new Date()
399
+ const pad = (n: number, len = 2) => String(n).padStart(len, '0')
400
+ const yyyy = now.getUTCFullYear()
401
+ const mm = pad(now.getUTCMonth() + 1)
402
+ const dd = pad(now.getUTCDate())
403
+ const hh = pad(now.getUTCHours())
404
+ const mi = pad(now.getUTCMinutes())
405
+ const ss = pad(now.getUTCSeconds())
406
+ const ms = pad(now.getUTCMilliseconds(), 3)
407
+ return `${yyyy}${mm}${dd}T${hh}${mi}${ss}.${ms}Z`
408
+ }
409
+
410
+ export interface RunResult {
411
+ ok: boolean
412
+ runId: string
413
+ manifestPath: string
414
+ backupFilesDir: string
415
+ warnings: string[]
416
+ errors: string[]
417
+ files: FileInfo[]
418
+ }
419
+
420
+ export async function run(memoryDir: string | null | undefined = null): Promise<RunResult> {
421
+ const root = resolveMemoryRoot(memoryDir as any)
422
+ const insp = await inspect(root)
423
+ if (insp.errors.length > 0) {
424
+ return {
425
+ ok: false,
426
+ runId: '',
427
+ manifestPath: '',
428
+ backupFilesDir: '',
429
+ warnings: insp.warnings,
430
+ errors: insp.errors,
431
+ files: insp.files,
432
+ }
433
+ }
434
+ const runId = utcRunId()
435
+ const manifestPath = backupManifestPath(root, runId)
436
+ const backupFilesDir = backupFilesDirPath(root, runId)
437
+ mkdirSync(backupFilesDir, { recursive: true })
438
+ mkdirSync(dirname(manifestPath), { recursive: true })
439
+
440
+ // byte-preserving copy
441
+ const manifestFiles: any[] = []
442
+ for (const f of insp.files) {
443
+ if (!f.exists) {
444
+ manifestFiles.push({
445
+ relative: f.relative,
446
+ path: f.path,
447
+ exists: false,
448
+ bytes: 0,
449
+ sha256: null,
450
+ kind: f.kind,
451
+ entries: f.entries ?? 0,
452
+ malformedLines: f.malformedLines,
453
+ })
454
+ continue
455
+ }
456
+ const dest = join(backupFilesDir, f.relative)
457
+ mkdirSync(dirname(dest), { recursive: true })
458
+ // byte-preserving copy
459
+ copyFileSync(f.path, dest)
460
+ // verify backup matches
461
+ const backupRaw = readFileSync(dest)
462
+ const backupSha = sha256Hex(backupRaw)
463
+ if (backupSha !== f.sha256) {
464
+ throw new Error(`backup sha mismatch for ${f.relative}`)
465
+ }
466
+ manifestFiles.push({
467
+ relative: f.relative,
468
+ path: f.path,
469
+ exists: true,
470
+ bytes: f.bytes,
471
+ sha256: f.sha256,
472
+ kind: f.kind,
473
+ entries: f.entries,
474
+ ids: f.ids,
475
+ malformedLines: f.malformedLines,
476
+ valid: f.valid,
477
+ })
478
+ }
479
+
480
+ const manifest = {
481
+ version: 1,
482
+ runId,
483
+ createdAt: new Date().toISOString(),
484
+ root,
485
+ files: manifestFiles,
486
+ inventory: insp.inventory,
487
+ warnings: insp.warnings,
488
+ errors: insp.errors,
489
+ backupFilesDir,
490
+ }
491
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
492
+
493
+ // write schema.json (only after all required data parses — we already did)
494
+ const sPath = schemaPath(root)
495
+ mkdirSync(dirname(sPath), { recursive: true })
496
+ const schema = {
497
+ version: 1,
498
+ runId,
499
+ migratedAt: new Date().toISOString(),
500
+ root,
501
+ inventory: insp.inventory,
502
+ manifest: manifestPath,
503
+ }
504
+ writeFileSync(sPath, JSON.stringify(schema, null, 2), 'utf8')
505
+
506
+ // journal
507
+ const jPath = journalPath(root)
508
+ mkdirSync(dirname(jPath), { recursive: true })
509
+ const journalEntry = {
510
+ runId,
511
+ at: new Date().toISOString(),
512
+ action: 'run',
513
+ ok: true,
514
+ warnings: insp.warnings,
515
+ inventory: insp.inventory,
516
+ manifest: manifestPath,
517
+ }
518
+ appendFileSync(jPath, JSON.stringify(journalEntry) + '\n', 'utf8')
519
+
520
+ // clear any previous write-block on successful run
521
+ const blockPath = join(maestroMetaDir(root), 'write-block.json')
522
+ if (existsSync(blockPath)) {
523
+ try {
524
+ rmSync(blockPath, { force: true })
525
+ } catch {}
526
+ }
527
+
528
+ return {
529
+ ok: true,
530
+ runId,
531
+ manifestPath,
532
+ backupFilesDir,
533
+ warnings: insp.warnings,
534
+ errors: [],
535
+ files: insp.files,
536
+ }
537
+ }
538
+
539
+ export interface VerifyResult {
540
+ ok: boolean
541
+ runId: string
542
+ manifestPath: string
543
+ mismatches: string[]
544
+ warnings: string[]
545
+ }
546
+
547
+ function latestRunId(root: string): string | null {
548
+ const dir = backupsDir(root)
549
+ try {
550
+ const entries = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort()
551
+ if (entries.length === 0) return null
552
+ return entries[entries.length - 1]
553
+ } catch {
554
+ return null
555
+ }
556
+ }
557
+
558
+ export async function verify(memoryDir: string | null | undefined = null, runId?: string): Promise<VerifyResult> {
559
+ const root = resolveMemoryRoot(memoryDir as any)
560
+ let targetRunId = runId ?? null
561
+ if (!targetRunId) {
562
+ // try schema.json first
563
+ try {
564
+ const schema = JSON.parse(readFileSync(schemaPath(root), 'utf8'))
565
+ targetRunId = schema.runId ?? null
566
+ } catch {}
567
+ if (!targetRunId) targetRunId = latestRunId(root)
568
+ }
569
+ if (!targetRunId) {
570
+ return { ok: false, runId: '', manifestPath: '', mismatches: ['no manifest found'], warnings: [] }
571
+ }
572
+ const manifestPath = backupManifestPath(root, targetRunId)
573
+ let manifest: any
574
+ try {
575
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
576
+ } catch (e: any) {
577
+ return { ok: false, runId: targetRunId, manifestPath, mismatches: [`manifest not found: ${e?.message}`], warnings: [] }
578
+ }
579
+
580
+ const mismatches: string[] = []
581
+ const warnings: string[] = []
582
+
583
+ // check each file in manifest
584
+ for (const f of manifest.files) {
585
+ if (!f.exists) {
586
+ // file was missing at backup time; should still be missing
587
+ const curExists = existsSync(f.path)
588
+ if (curExists) {
589
+ mismatches.push(`unexpected file appeared: ${f.relative}`)
590
+ }
591
+ continue
592
+ }
593
+ const curExists = existsSync(f.path)
594
+ if (!curExists) {
595
+ mismatches.push(`missing file: ${f.relative} (expected sha ${f.sha256})`)
596
+ continue
597
+ }
598
+ const cur = readFileSync(f.path)
599
+ const curSha = sha256Hex(cur)
600
+ if (curSha !== f.sha256) {
601
+ mismatches.push(`digest mismatch: ${f.relative} (expected ${f.sha256?.slice(0, 8)}, got ${curSha.slice(0, 8)}, bytes ${f.bytes} -> ${cur.length})`)
602
+ }
603
+ if (cur.length !== f.bytes) {
604
+ mismatches.push(`byte count mismatch: ${f.relative} (${f.bytes} -> ${cur.length})`)
605
+ }
606
+ }
607
+
608
+ // also compare inventory via re-parse (entry counts + todo IDs)
609
+ const currentInspect = await inspect(root)
610
+ const manifestInv = manifest.inventory
611
+ if (manifestInv) {
612
+ if (currentInspect.inventory.memoryEntries !== manifestInv.memoryEntries) {
613
+ mismatches.push(`inventory mismatch: memoryEntries ${manifestInv.memoryEntries} -> ${currentInspect.inventory.memoryEntries}`)
614
+ }
615
+ if (currentInspect.inventory.todoIdsCount !== manifestInv.todoIdsCount) {
616
+ mismatches.push(`inventory mismatch: todoIds ${manifestInv.todoIdsCount} -> ${currentInspect.inventory.todoIdsCount}`)
617
+ }
618
+ if (currentInspect.inventory.queueValid !== manifestInv.queueValid) {
619
+ mismatches.push(`inventory mismatch: queueValid ${manifestInv.queueValid} -> ${currentInspect.inventory.queueValid}`)
620
+ }
621
+ // check todo IDs set equality if counts match but ids differ
622
+ if (manifestInv.todoIds && Array.isArray(manifestInv.todoIds)) {
623
+ const curIdsSorted = [...currentInspect.inventory.todoIds].sort()
624
+ const manIdsSorted = [...manifestInv.todoIds].sort()
625
+ if (curIdsSorted.join(',') !== manIdsSorted.join(',')) {
626
+ // only report if not already reported via count
627
+ if (currentInspect.inventory.todoIdsCount === manifestInv.todoIdsCount) {
628
+ mismatches.push(`todo ID set mismatch: ${manIdsSorted.join(',')} -> ${curIdsSorted.join(',')}`)
629
+ }
630
+ }
631
+ }
632
+ }
633
+
634
+ const ok = mismatches.length === 0
635
+ const blockPath = join(maestroMetaDir(root), 'write-block.json')
636
+ if (!ok) {
637
+ // write-block on mismatch
638
+ mkdirSync(dirname(blockPath), { recursive: true })
639
+ const block = {
640
+ blocked: true,
641
+ runId: targetRunId,
642
+ at: new Date().toISOString(),
643
+ mismatches,
644
+ manifest: manifestPath,
645
+ }
646
+ writeFileSync(blockPath, JSON.stringify(block, null, 2), 'utf8')
647
+ // also append to journal
648
+ try {
649
+ const jPath = journalPath(root)
650
+ mkdirSync(dirname(jPath), { recursive: true })
651
+ appendFileSync(jPath, JSON.stringify({ runId: targetRunId, at: new Date().toISOString(), action: 'verify', ok: false, mismatches }) + '\n', 'utf8')
652
+ } catch {}
653
+ } else {
654
+ // on success, clear block if exists and log verify success
655
+ if (existsSync(blockPath)) {
656
+ try {
657
+ rmSync(blockPath, { force: true })
658
+ } catch {}
659
+ }
660
+ try {
661
+ const jPath = journalPath(root)
662
+ mkdirSync(dirname(jPath), { recursive: true })
663
+ appendFileSync(jPath, JSON.stringify({ runId: targetRunId, at: new Date().toISOString(), action: 'verify', ok: true }) + '\n', 'utf8')
664
+ } catch {}
665
+ }
666
+
667
+ return { ok, runId: targetRunId, manifestPath, mismatches, warnings }
668
+ }
669
+
670
+ export function isWriteBlocked(memoryDir: string | null | undefined = null): boolean {
671
+ const root = resolveMemoryRoot(memoryDir as any)
672
+ const blockPath = join(maestroMetaDir(root), 'write-block.json')
673
+ if (!existsSync(blockPath)) return false
674
+ try {
675
+ const data = JSON.parse(readFileSync(blockPath, 'utf8'))
676
+ return data.blocked === true
677
+ } catch {
678
+ return true
679
+ }
680
+ }
681
+
682
+ export function clearWriteBlock(memoryDir: string | null | undefined = null): void {
683
+ const root = resolveMemoryRoot(memoryDir as any)
684
+ const blockPath = join(maestroMetaDir(root), 'write-block.json')
685
+ if (existsSync(blockPath)) {
686
+ try {
687
+ rmSync(blockPath, { force: true })
688
+ } catch {}
689
+ }
690
+ }
691
+
692
+ export interface RollbackResult {
693
+ ok: boolean
694
+ runId: string
695
+ manifestPath: string
696
+ restored: number
697
+ errors: string[]
698
+ }
699
+
700
+ /**
701
+ * Restore files from a backup manifest byte-identical.
702
+ * Used for M4-PR-A rehearsal: exercise and test rollback against copied schema.
703
+ * - Restores each existing file from backupFilesDir/<relative> to its original path.
704
+ * - Removes files that appeared after backup if they were absent at backup time.
705
+ * - Clears write-block and appends journal entry on success.
706
+ */
707
+ export async function rollback(
708
+ memoryDir: string | null | undefined = null,
709
+ runId?: string,
710
+ ): Promise<RollbackResult> {
711
+ const root = resolveMemoryRoot(memoryDir as any)
712
+ let targetRunId = runId ?? null
713
+ if (!targetRunId) {
714
+ try {
715
+ const schema = JSON.parse(readFileSync(schemaPath(root), 'utf8'))
716
+ targetRunId = schema.runId ?? null
717
+ } catch {}
718
+ if (!targetRunId) targetRunId = latestRunId(root)
719
+ }
720
+ if (!targetRunId) {
721
+ return { ok: false, runId: '', manifestPath: '', restored: 0, errors: ['no manifest found'] }
722
+ }
723
+ const manifestPath = backupManifestPath(root, targetRunId)
724
+ let manifest: any
725
+ try {
726
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
727
+ } catch (e: any) {
728
+ return { ok: false, runId: targetRunId, manifestPath, restored: 0, errors: [`manifest not found: ${e?.message}`] }
729
+ }
730
+ const backupFilesDir: string = manifest.backupFilesDir ?? backupFilesDirPath(root, targetRunId)
731
+ let restored = 0
732
+ const errors: string[] = []
733
+ for (const f of manifest.files) {
734
+ const dest = f.path as string
735
+ const src = join(backupFilesDir, f.relative as string)
736
+ try {
737
+ if (!f.exists) {
738
+ // file was absent at backup time; remove if it appeared
739
+ if (existsSync(dest)) {
740
+ rmSync(dest, { force: true })
741
+ restored += 1
742
+ }
743
+ continue
744
+ }
745
+ // file existed; restore byte-identical from backup
746
+ if (!existsSync(src)) {
747
+ errors.push(`backup missing for ${f.relative}`)
748
+ continue
749
+ }
750
+ mkdirSync(dirname(dest), { recursive: true })
751
+ copyFileSync(src, dest)
752
+ // verify restored sha matches manifest
753
+ const restoredRaw = readFileSync(dest)
754
+ const restoredSha = sha256Hex(restoredRaw)
755
+ if (restoredSha !== f.sha256) {
756
+ errors.push(`restore sha mismatch for ${f.relative} (expected ${f.sha256?.slice(0, 8)}, got ${restoredSha.slice(0, 8)})`)
757
+ } else {
758
+ restored += 1
759
+ }
760
+ } catch (e: any) {
761
+ errors.push(`restore failed for ${f.relative}: ${e?.message ?? String(e)}`)
762
+ }
763
+ }
764
+
765
+ // clear write-block if any
766
+ const blockPath = join(maestroMetaDir(root), 'write-block.json')
767
+ if (existsSync(blockPath)) {
768
+ try {
769
+ rmSync(blockPath, { force: true })
770
+ } catch {}
771
+ }
772
+
773
+ // journal
774
+ try {
775
+ const jPath = journalPath(root)
776
+ mkdirSync(dirname(jPath), { recursive: true })
777
+ appendFileSync(
778
+ jPath,
779
+ JSON.stringify({ runId: targetRunId, at: new Date().toISOString(), action: 'rollback', ok: errors.length === 0, restored, errors }) +
780
+ '\n',
781
+ 'utf8',
782
+ )
783
+ } catch {}
784
+
785
+ return { ok: errors.length === 0, runId: targetRunId, manifestPath, restored, errors }
786
+ }