memoir-cli 3.12.0 → 3.14.0

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 (73) hide show
  1. package/README.md +128 -137
  2. package/bin/memoir-work.js +9 -0
  3. package/bin/memoir.js +50 -8
  4. package/docs/AUDIT-REMEDIATION.md +55 -0
  5. package/docs/CASE_TAPE_AMNESIA.md +39 -0
  6. package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
  7. package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
  8. package/docs/MCP-V2-MIGRATION.md +17 -0
  9. package/docs/PROJECT-HANDOFF.md +255 -0
  10. package/docs/PROJECT-VIEW-DEBUG.md +66 -0
  11. package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
  12. package/docs/RELEASE-3.14-VALIDATION.md +36 -0
  13. package/docs/RELIABILITY-ROLLOUT.md +57 -0
  14. package/docs/RETRIEVAL-INDEX.md +45 -0
  15. package/docs/RETRIEVAL-RESULTS.md +26 -0
  16. package/docs/SPEC.md +684 -0
  17. package/evals/CONTINUITY-PROTOCOL.md +45 -0
  18. package/evals/cases.json +200 -0
  19. package/evals/results/retrieval-2026-09-05.json +5333 -0
  20. package/evals/retrieval-performance.mjs +99 -0
  21. package/evals/run.mjs +87 -0
  22. package/package.json +13 -5
  23. package/src/adapters/index.js +13 -6
  24. package/src/adapters/restore.js +83 -36
  25. package/src/cloud/storage.js +130 -93
  26. package/src/commands/activate.js +18 -7
  27. package/src/commands/cloud.js +55 -4
  28. package/src/commands/consolidate.js +49 -10
  29. package/src/commands/diff.js +2 -2
  30. package/src/commands/doctor.js +3 -3
  31. package/src/commands/push.js +156 -161
  32. package/src/commands/recall.js +1 -1
  33. package/src/commands/restore.js +32 -44
  34. package/src/commands/resume.js +15 -164
  35. package/src/commands/session.js +51 -9
  36. package/src/commands/snapshot.js +6 -7
  37. package/src/commands/status.js +23 -1
  38. package/src/commands/upgrade.js +11 -9
  39. package/src/commands/validate.js +3 -0
  40. package/src/commands/view.js +2 -2
  41. package/src/commands/why.js +4 -3
  42. package/src/config.js +9 -40
  43. package/src/context/capture.js +126 -32
  44. package/src/context/handoffs.js +72 -0
  45. package/src/events/summary.js +122 -0
  46. package/src/integrations/setup.js +88 -0
  47. package/src/mcp.js +105 -152
  48. package/src/memory/lexical-index.js +65 -0
  49. package/src/memory/repository.js +16 -0
  50. package/src/memory/scope.js +65 -0
  51. package/src/memory/search.js +165 -70
  52. package/src/memory/store.js +141 -0
  53. package/src/providers/index.js +182 -51
  54. package/src/providers/restore.js +5 -1
  55. package/src/security/encryption.js +34 -60
  56. package/src/security/files.js +155 -0
  57. package/src/session/brief.js +47 -0
  58. package/src/session/inject.js +12 -6
  59. package/src/session/lock.js +39 -118
  60. package/src/session/migrations.js +6 -0
  61. package/src/session/render.js +34 -4
  62. package/src/session/state.js +200 -33
  63. package/src/work/cli.js +64 -0
  64. package/src/work/errors.js +8 -0
  65. package/src/work/server.js +28 -0
  66. package/src/work/setup.js +96 -0
  67. package/src/work/store.js +340 -0
  68. package/src/work/ui/app.js +205 -0
  69. package/src/work/ui/index.html +30 -0
  70. package/src/work/ui/style.css +3 -0
  71. package/src/work/view.js +93 -0
  72. package/src/workspace/tracker.js +84 -332
  73. package/supabase/migrations/202609050001_backup_versions.sql +50 -0
@@ -5,7 +5,7 @@ import boxen from 'boxen';
5
5
  import ora from 'ora';
6
6
  import gradient from 'gradient-string';
7
7
  import os from 'os';
8
- import { execSync } from 'child_process';
8
+ import { execSync, execFileSync } from 'child_process';
9
9
  import { getConfig } from '../config.js';
10
10
  import { adapters } from '../adapters/index.js';
11
11
  import { scanForSecrets as scanTextForSecrets } from '../security/scanner.js';
@@ -100,7 +100,7 @@ export async function doctorCommand(options = {}) {
100
100
  if (config?.provider === 'git' && gitInstalled && config.gitRepo) {
101
101
  spinner.text = 'Testing remote connectivity...';
102
102
  try {
103
- execSync(`git ls-remote ${config.gitRepo} HEAD`, { stdio: 'pipe', timeout: 10000 });
103
+ execFileSync('git', ['ls-remote', config.gitRepo, 'HEAD'], { stdio: 'pipe', timeout: 10000 });
104
104
  lines.push(pass(`Remote: ${chalk.gray(config.gitRepo)} reachable`));
105
105
  } catch {
106
106
  lines.push(fail(`Remote: cannot reach ${chalk.gray(config.gitRepo)}`));
@@ -185,7 +185,7 @@ export async function doctorCommand(options = {}) {
185
185
  lines.push(chalk.bold.white(' Last Sync'));
186
186
  try {
187
187
  const tmpDir = path.join(os.tmpdir(), 'memoir-doctor-' + Date.now());
188
- execSync(`git clone --depth 1 ${config.gitRepo} ${tmpDir}`, { stdio: 'pipe', timeout: 15000 });
188
+ execFileSync('git', ['clone', '--depth', '1', '--', config.gitRepo, tmpDir], { stdio: 'pipe', timeout: 15000 });
189
189
  const lastCommit = execSync('git log -1 --format=%cr', { cwd: tmpDir, stdio: 'pipe' }).toString().trim();
190
190
  const lastMsg = execSync('git log -1 --format=%s', { cwd: tmpDir, stdio: 'pipe' }).toString().trim();
191
191
  await fs.remove(tmpDir);
@@ -1,3 +1,5 @@
1
+ import { projectIdentity } from '../memory/scope.js';
2
+ import { restoreStoredMemories, stageMemories } from '../memory/store.js';
1
3
  import chalk from 'chalk';
2
4
  import fs from 'fs-extra';
3
5
  import path from 'path';
@@ -8,85 +10,70 @@ import gradient from 'gradient-string';
8
10
  import { execFileSync } from 'child_process';
9
11
  import { getConfig, autoSetup } from '../config.js';
10
12
  import { extractMemories, adapters } from '../adapters/index.js';
11
- import { syncToLocal, syncToGit } from '../providers/index.js';
13
+ import { syncToLocal, syncToGit, withLocalBackupLock, cloneForSync, remoteHasFile, checkoutFromRemote } from '../providers/index.js';
12
14
  import inquirer from 'inquirer';
13
15
  import { appendEvent } from '../events/log.js';
14
- import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions, isQuality } from '../context/capture.js';
16
+ import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions, isQuality, enrichWithGit } from '../context/capture.js';
17
+ import { saveHandoff, handoffFilename } from '../context/handoffs.js';
15
18
  import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
16
- import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
19
+ import { encryptDirectory, decryptDirectory, createVerifyToken } from '../security/encryption.js';
17
20
  import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
18
21
  import { scanWorkspace } from '../workspace/tracker.js';
19
22
  import { promptActivate } from './activate.js';
20
23
  import { paths as sessionPaths, readSession, writeSession, mergeSessions, addNote, recordSessionEnd } from '../session/state.js';
21
24
  import { migrateSessionData } from '../session/migrations.js';
22
25
  import { withSessionLock } from '../session/lock.js';
26
+ import { listSafeFiles, readSafeFile, writeSafeFile } from '../security/files.js';
23
27
  import { renderSession } from '../session/render.js';
24
28
  import { injectInto, detectAvailableTargets } from '../session/inject.js';
25
29
 
26
- // Best-effort fetch of the CURRENT remote session.json, so push.js can merge
27
- // before overwrite instead of blindly clobbering it (see below). Returns the
28
- // remote session state (already migrated to SCHEMA_VERSION) or null if the
29
- // Tri-state, because the difference is destructive: 'none' means nothing is
30
- // there (safe to write ours), 'ok' carries the remote session for merging,
31
- // and 'unreadable' means A REMOTE EXISTS BUT WE CANNOT READ IT — encrypted,
32
- // slow clone, corrupt JSON. On 'unreadable' the caller MUST NOT stage
33
- // session.json at all, so the remote copy survives the mirror sweep.
34
- // The old boolean version returned null for 'unreadable', which collapsed
35
- // to merged = local and silently clobbered the other machine's state —
36
- // worst on encrypted remotes, where the "protection" was a complete no-op.
37
- async function fetchRemoteSessionBestEffort(config) {
30
+ // A failed read never authorizes replacing an existing snapshot. Encrypted
31
+ // snapshots are authenticated before merging, using the same user-held key.
32
+ async function fetchRemoteSessionBestEffort(config, getPassphrase) {
33
+ let cloneDir = null;
34
+ let plainDir = null;
38
35
  try {
39
- if (config.provider === 'local' || config.provider?.includes?.('local')) {
40
- const resolvedDest = (config.localPath || '').replace(/^~/, os.homedir());
41
- if (!resolvedDest) return { status: 'none', session: null };
42
- if (await fs.pathExists(path.join(resolvedDest, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted
43
- const remotePath = path.join(resolvedDest, 'session.json');
44
- if (!(await fs.pathExists(remotePath))) return { status: 'none', session: null };
45
- try {
46
- const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
47
- const { state } = migrateSessionData(raw);
48
- return { status: 'ok', session: state };
49
- } catch {
50
- return { status: 'unreadable', session: null }; // exists but corrupt
51
- }
52
- }
53
-
54
- if (config.provider === 'git' || config.provider?.includes?.('git')) {
55
- const repoUrl = config.gitRepo;
56
- if (!repoUrl) return { status: 'none', session: null };
57
- const peekDir = path.join(os.tmpdir(), `memoir-push-peek-${Date.now()}`);
58
- await fs.ensureDir(peekDir);
59
- try {
60
- try {
61
- // Same budget as the real sync clone — the old 30s peek against a
62
- // 60s sync meant a 35-second clone failed the peek but succeeded
63
- // the mirror, deterministically wiping the remote session.
64
- execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: peekDir, stdio: 'ignore', timeout: 120000 });
65
- } catch {
66
- // Unreachable or first push. If the LATER sync clone succeeds
67
- // where this one failed, treating it as 'none' would clobber —
68
- // but with equal timeouts that window is a genuine remote flap,
69
- // and 'unreadable' here would wedge first-time pushes forever.
70
- return { status: 'none', session: null };
71
- }
72
- if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted
73
- const remotePath = path.join(peekDir, 'session.json');
74
- if (!(await fs.pathExists(remotePath))) return { status: 'none', session: null };
75
- try {
76
- const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
77
- const { state } = migrateSessionData(raw);
78
- return { status: 'ok', session: state };
79
- } catch {
80
- return { status: 'unreadable', session: null };
36
+ let source;
37
+ if (config.provider?.includes('local')) {
38
+ source = (config.localPath || '').replace(/^~/, os.homedir());
39
+ if (!source || !await fs.pathExists(source)) return { session: null };
40
+ } else if (config.provider?.includes('git')) {
41
+ cloneDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-push-peek-'));
42
+ cloneForSync(config.gitRepo, cloneDir, { timeout: 120000 });
43
+ source = cloneDir;
44
+ // Materialize the complete tree before merging a snapshot. Correctness
45
+ // comes before the old optimization that omitted encrypted session data.
46
+ if (remoteHasFile(cloneDir, 'manifest.enc') || config.encrypt !== false) {
47
+ if (remoteHasFile(cloneDir, '.')) {
48
+ if (!checkoutFromRemote(cloneDir, '.')) throw new Error('Could not read prior backup');
81
49
  }
82
- } finally {
83
- await fs.remove(peekDir).catch(() => {});
50
+ } else if (remoteHasFile(cloneDir, 'session.json') && !checkoutFromRemote(cloneDir, 'session.json')) {
51
+ throw new Error('Could not read prior session');
84
52
  }
53
+ } else { throw new Error('Unsupported backup provider'); }
54
+
55
+ if (cloneDir && remoteHasFile(cloneDir, 'projects.json') && !checkoutFromRemote(cloneDir, 'projects.json')) throw new Error('Could not read prior project mapping');
56
+ if (cloneDir && remoteHasFile(cloneDir, 'memoir-memories') && !checkoutFromRemote(cloneDir, 'memoir-memories')) throw new Error('Could not read prior memory records');
57
+ const encrypted = await fs.pathExists(path.join(source, 'manifest.enc'));
58
+ if (encrypted) {
59
+ if (config.encrypt === false) throw new Error('The destination is encrypted. Restore it before choosing a new plaintext destination.');
60
+ plainDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-prior-'));
61
+ await decryptDirectory(source, plainDir, await getPassphrase());
62
+ source = plainDir;
63
+ }
64
+ const file = path.join(source, 'session.json');
65
+ let session = null;
66
+ if (await fs.pathExists(file)) {
67
+ const migrated = migrateSessionData(JSON.parse((await readSafeFile(source, 'session.json')).toString('utf8')));
68
+ if (migrated.future) throw new Error('Backup uses a newer session schema; upgrade Memoir first.');
69
+ session = migrated.state;
85
70
  }
86
- } catch {
87
- // Never let a merge-fetch failure block the push.
71
+ return { session, cloneDir, plainDir, source, encrypted };
72
+ } catch (err) {
73
+ if (cloneDir) await fs.remove(cloneDir).catch(() => {});
74
+ if (plainDir) await fs.remove(plainDir).catch(() => {});
75
+ throw new Error('Previous backup could not be verified; it was left unchanged. ' + err.message);
88
76
  }
89
- return { status: 'none', session: null };
90
77
  }
91
78
 
92
79
  // Recursively scan every staged file (the REAL tool memory/config files about
@@ -143,7 +130,7 @@ export async function pushCommand(options = {}) {
143
130
  const setupSpinner = ora({ text: chalk.gray('Setting up memoir automatically...'), spinner: 'dots' }).start();
144
131
  config = await autoSetup();
145
132
  if (config) {
146
- setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo}`));
133
+ setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo || config.localPath}`));
147
134
  } else {
148
135
  setupSpinner.fail(chalk.red('Could not detect GitHub username'));
149
136
  console.log('\n' + boxen(
@@ -154,6 +141,11 @@ export async function pushCommand(options = {}) {
154
141
  }
155
142
  }
156
143
 
144
+ // Serialize the complete read/merge/write cycle, including encrypted reads.
145
+ if (config.provider?.includes('local') && !options.localLockHeld) {
146
+ return withLocalBackupLock(config, () => pushCommand({ ...options, localLockHeld: true }));
147
+ }
148
+
157
149
  console.log();
158
150
  const spinner = ora({ text: chalk.gray('Scanning for AI tools...'), spinner: 'dots' }).start();
159
151
 
@@ -161,6 +153,18 @@ export async function pushCommand(options = {}) {
161
153
  await fs.ensureDir(stagingDir);
162
154
 
163
155
  let encryptedDir = null;
156
+ let remoteCloneDir = null;
157
+ let remotePlainDir = null;
158
+ let backupPassphrase = process.env.MEMOIR_PASSPHRASE || '';
159
+ const getPassphrase = async () => {
160
+ if (backupPassphrase.length >= 6) return backupPassphrase;
161
+ if (!process.stdin.isTTY) throw new Error('Encrypted backup requires MEMOIR_PASSPHRASE; no files were uploaded.');
162
+ spinner.stop();
163
+ const answer = await inquirer.prompt([{ type: 'password', name: 'passphrase', message: 'Encryption passphrase:', mask: '*', validate: value => value.length >= 6 || 'Use at least 6 characters' }]);
164
+ backupPassphrase = answer.passphrase;
165
+ spinner.start();
166
+ return backupPassphrase;
167
+ };
164
168
 
165
169
  try {
166
170
  // Profile-level tool filter (config.only) merged with CLI --only flag
@@ -168,7 +172,7 @@ export async function pushCommand(options = {}) {
168
172
  const onlyFilter = onlyRaw ? onlyRaw.split(',').map(t => t.trim().toLowerCase()) : null;
169
173
  const foundAny = await extractMemories(stagingDir, spinner, onlyFilter);
170
174
 
171
- if (!foundAny) {
175
+ if (!foundAny && !await fs.pathExists(sessionPaths.session)) {
172
176
  spinner.stop();
173
177
  console.log('\n' + boxen(
174
178
  chalk.yellow('No AI tools detected on this machine.\n\n') +
@@ -185,24 +189,18 @@ export async function pushCommand(options = {}) {
185
189
  try {
186
190
  const sessions = findClaudeSessions();
187
191
  if (sessions.length > 0) {
188
- const parsed = parseSession(sessions[0].path);
189
- if (parsed.userMessages.length > 0) {
192
+ const parsed = enrichWithGit(parseSession(sessions[0].path));
193
+ if (parsed.cwd && projectIdentity(parsed.cwd) === projectIdentity() && parsed.userMessages.length > 0) {
190
194
  // Scan the generated handoff for any remaining secrets
191
195
  const handoff = generateContextHandoff(parsed);
192
196
  const { found, clean } = scanForSecrets(handoff);
193
197
 
194
- // Save handoff to staging dir
195
- const handoffDir = path.join(stagingDir, 'handoffs');
196
- await fs.ensureDir(handoffDir);
197
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
198
- await fs.writeFile(path.join(handoffDir, `${timestamp}-claude.md`), clean);
199
- await fs.writeFile(path.join(handoffDir, 'latest.md'), clean);
200
-
201
- // Also save locally for memoir resume
202
- const localHandoffDir = path.join(os.homedir(), '.config', 'memoir', 'handoffs');
203
- await fs.ensureDir(localHandoffDir);
204
- await fs.writeFile(path.join(localHandoffDir, `${timestamp}-claude.md`), clean);
205
- await fs.writeFile(path.join(localHandoffDir, 'latest.md'), clean);
198
+ // Staged copy for upload + local copy for `memoir resume`, same
199
+ // filename; the local dir is pruned to a bounded window.
200
+ await saveHandoff(clean, {
201
+ dirs: [path.join(stagingDir, 'handoffs'), path.join(os.homedir(), '.config', 'memoir', 'handoffs')],
202
+ filename: handoffFilename(),
203
+ });
206
204
 
207
205
  // Quality filter: auto-extracted decisions come from regex patterns
208
206
  // that sometimes catch table cells, prose fragments, or truncated
@@ -223,13 +221,7 @@ export async function pushCommand(options = {}) {
223
221
  };
224
222
  const qualityDecisions = parsed.decisions.filter(d => isQuality(decisionText(d)));
225
223
 
226
- // Persist decisions to Claude's memory so they survive across sessions
227
- let decisionCount = 0;
228
- if (qualityDecisions.length > 0) {
229
- try {
230
- decisionCount = persistDecisions(qualityDecisions);
231
- } catch {}
232
- }
224
+ let decisionCount = qualityDecisions.length;
233
225
 
234
226
  // Also feed structured decisions into session.json so they appear in
235
227
  // the pinned block and sync cross-machine. Dedupe against anything
@@ -250,15 +242,22 @@ export async function pushCommand(options = {}) {
250
242
  // pinned block. Emit no why rather than a fake one.
251
243
  const ctx = String(d.context || '').trim();
252
244
  const restates = !ctx || ctx.toLowerCase() === text.trim().toLowerCase();
253
- await addNote(text, { why: restates ? undefined : `auto-captured: ${ctx.slice(0, 80)}` });
245
+ await addNote(text, { project: parsed.cwd, why: restates ? undefined : `auto-captured: ${ctx.slice(0, 80)}` });
254
246
  }
255
- // Record a session summary in history for "recent sessions" section
247
+ // Record a session summary in history for "recent sessions" section.
248
+ // Project + branch + the last thing the user asked for — the old
249
+ // summary was the transcript's random slug ("Worked on
250
+ // calm-bubbling-liskov"), which told the next session nothing.
256
251
  const filesList = Array.from(parsed.filesWritten || []).slice(0, 10);
257
252
  const durationMin = (parsed.firstTimestamp && parsed.lastTimestamp)
258
253
  ? Math.floor((new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp)) / 60000)
259
254
  : null;
260
- const summary = parsed.slug ? `Worked on ${parsed.slug}` : `${filesList.length} file(s) touched`;
261
- await recordSessionEnd({ summary, filesTouched: filesList, durationMin });
255
+ const lastAsk = [...parsed.userMessages].reverse().find((m) => m.length > 10) || '';
256
+ const project = parsed.cwd ? path.basename(parsed.cwd) : (parsed.slug || 'session');
257
+ const branch = parsed.gitBranch && parsed.gitBranch !== 'HEAD' ? ` (${parsed.gitBranch})` : '';
258
+ const ask = lastAsk.replace(/\s+/g, ' ').trim().slice(0, 90);
259
+ const summary = ask ? `${project}${branch}: ${ask}` : `${project}${branch}`;
260
+ await recordSessionEnd({ summary, filesTouched: filesList, durationMin, sessionId: parsed.sessionId || null, project: parsed.cwd });
262
261
  // Re-render into every detected tool so the pinned block reflects
263
262
  // what was just auto-captured from the .jsonl
264
263
  try {
@@ -302,60 +301,48 @@ export async function pushCommand(options = {}) {
302
301
  let workspaceManifest = null;
303
302
  spinner.text = chalk.gray('Scanning workspace...');
304
303
  try {
305
- workspaceManifest = await scanWorkspace(stagingDir, spinner);
306
- } catch {
307
- // Workspace scan is best-effort
304
+ if (options.workspace === true || config.workspace === true) workspaceManifest = await scanWorkspace(stagingDir, spinner);
305
+ } catch (err) {
306
+ throw new Error('Workspace capture failed: ' + err.message);
308
307
  }
309
308
 
310
- // Include session.json (continuity state) so it syncs across machines.
311
- //
312
- // MERGE-BEFORE-OVERWRITE: this used to be a blind fs.copy() of the LOCAL
313
- // session.json, and syncToGit/syncToLocal do a full-mirror overwrite of
314
- // the remote (clone-or-init, delete every tracked file, copy the local
315
- // staging dir wholesale over it, commit, push). Any machine that pushed
316
- // without having restored first would silently and completely destroy
317
- // whatever ANY OTHER machine had added to the remote in the interim —
318
- // goals, next-actions, decisions, everything. Not an edge case: it's the
319
- // default behavior of the most common operation in the tool (autopush
320
- // fires after every single Claude Code response).
321
- //
322
- // Best-effort fetch the current remote session.json first, migrate it,
323
- // and merge with mergeSessions (the same newest-timestamp-wins
324
- // union-by-text function restore.js already uses) BEFORE writing the
325
- // result to both the staging dir (for upload) and back to the local
326
- // session.json (so this machine also gains whatever the remote had that
327
- // it didn't) symmetric with restore.js instead of a blind overwrite.
328
- let sessionIncluded = false;
329
- let preserveRemoteSession = false;
330
- try {
331
- if (await fs.pathExists(sessionPaths.session)) {
332
- const { status, session: remote } = await fetchRemoteSessionBestEffort(config);
333
- if (status === 'unreadable') {
334
- // A remote session exists and we could not read it (encrypted,
335
- // slow, corrupt). Staging ours anyway would mirror-overwrite the
336
- // one copy we couldn't merge — the exact clobber this guard
337
- // exists to prevent. Leave session.json out of the staging dir
338
- // and tell the sync to leave the remote copy alone.
339
- preserveRemoteSession = true;
340
- try { appendEvent('sync_degraded', { reason: 'remote_session_unreadable' }); } catch {}
341
- } else {
342
- // Read AND merge AND write inside one lock. Reading outside it and
343
- // locking only the write is a check-then-act: a concurrent MCP
344
- // memoir_note in that window is silently dropped. This is the most
345
- // reachable instance of that bug — it sits on the autopush path.
346
- let merged;
347
- await withSessionLock(sessionPaths.sessionLock, async () => {
348
- const local = await readSession();
349
- merged = remote ? mergeSessions(local, remote) : local;
350
- if (remote) await writeSession(merged);
351
- });
352
- await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
353
- sessionIncluded = true;
309
+ const prior = await fetchRemoteSessionBestEffort(config, getPassphrase);
310
+ remoteCloneDir = prior.cloneDir || null;
311
+ remotePlainDir = prior.plainDir || null;
312
+ if (prior.source) await restoreStoredMemories(prior.source);
313
+ // Keep mappings for projects that exist only on another machine.
314
+ if (prior.source && await fs.pathExists(path.join(prior.source, 'projects.json'))) {
315
+ const remoteProjects = JSON.parse((await readSafeFile(prior.source, 'projects.json')).toString());
316
+ let localProjects = {};
317
+ if (await fs.pathExists(path.join(stagingDir, 'projects.json'))) localProjects = JSON.parse((await readSafeFile(stagingDir, 'projects.json')).toString());
318
+ await writeSafeFile(stagingDir, 'projects.json', JSON.stringify({ ...remoteProjects, ...localProjects }, null, 2));
319
+ }
320
+
321
+ await stageMemories(stagingDir);
322
+ // Re-encryption always starts with a complete prior snapshot. Overlay
323
+ // current files, preserve files absent on this machine, merge session below.
324
+ if ((config.encrypt !== false || prior.encrypted) && prior.source) {
325
+ const priorFiles = await listSafeFiles(prior.source).catch(async err => {
326
+ // A checked-out Git tree contains .git, which is transport metadata.
327
+ if (remoteCloneDir === prior.source) {
328
+ const files = execFileSync('git', ['ls-files', '-z'], { cwd: prior.source, encoding: 'utf8' }).split('\0').filter(Boolean);
329
+ return files;
354
330
  }
331
+ throw err;
332
+ });
333
+ for (const rel of priorFiles) {
334
+ if (rel.startsWith('memoir-memories/')) continue; // Already reconciled, including purge tombstones.
335
+ if (!await fs.pathExists(path.join(stagingDir, rel))) await writeSafeFile(stagingDir, rel, await readSafeFile(prior.source, rel));
355
336
  }
356
- } catch {
357
- // Best-effort — don't fail the push over this
358
337
  }
338
+ let merged;
339
+ await withSessionLock(sessionPaths.sessionLock, async () => {
340
+ const local = await readSession();
341
+ merged = prior.session ? mergeSessions(local, prior.session) : local;
342
+ await writeSession(merged);
343
+ });
344
+ await writeSafeFile(stagingDir, 'session.json', JSON.stringify(merged, null, 2));
345
+ const sessionIncluded = true;
359
346
 
360
347
  // Count what was found
361
348
  const found = [];
@@ -377,13 +364,8 @@ export async function pushCommand(options = {}) {
377
364
  // • --redact → strip secrets in place, then upload (sanitized)
378
365
  // • otherwise → WARN and continue
379
366
  // • background autopush → stay silent and continue
380
- // We deliberately do NOT hard-block. This is a zero-knowledge encrypted
381
- // backup of the user's OWN files; silently refusing to back up — which the
382
- // detached `autopush` Stop-hook path (stdio:'ignore', MEMOIR_AUTOPUSH=1, no
383
- // TTY) would hit on any false-positive match — is a worse failure than
384
- // backing up. A future `--strict` flag could fail-closed for the
385
- // encrypt-off / shared-destination case. Wrapped so a scanner error can
386
- // never break the push.
367
+ // Redaction is explicit and heuristic. Plaintext backups can contain
368
+ // secrets; a warning does not promise that every secret was detected.
387
369
  const background = process.env.MEMOIR_AUTOPUSH === '1';
388
370
  try {
389
371
  const { findings } = await scanStagedFiles(stagingDir, { redact: options.redact === true });
@@ -416,7 +398,7 @@ export async function pushCommand(options = {}) {
416
398
  // Encrypt if enabled (or ask on first push if not configured)
417
399
  let uploadDir = stagingDir;
418
400
  let encrypted = false;
419
- let shouldEncrypt = config.encrypt;
401
+ let shouldEncrypt = prior.encrypted || config.encrypt;
420
402
 
421
403
  if (shouldEncrypt === undefined) {
422
404
  if (background || !process.stdin.isTTY) {
@@ -426,7 +408,8 @@ export async function pushCommand(options = {}) {
426
408
  // otherwise push unencrypted THIS ONCE without persisting the
427
409
  // choice — a backup beats no backup, and the next interactive push
428
410
  // still gets the real question (default Yes).
429
- shouldEncrypt = Boolean(process.env.MEMOIR_PASSPHRASE);
411
+ if (!process.env.MEMOIR_PASSPHRASE) throw new Error('Choose encryption explicitly with memoir init before sending this backup, or set MEMOIR_PASSPHRASE.');
412
+ shouldEncrypt = true;
430
413
  if (!shouldEncrypt) {
431
414
  config.encrypt = undefined; // do not let the fallthrough persist "off"
432
415
  }
@@ -464,17 +447,7 @@ export async function pushCommand(options = {}) {
464
447
  if (shouldEncrypt) {
465
448
  // Headless pushes can supply the passphrase via env; interactive
466
449
  // pushes are asked as before.
467
- let passphrase = process.env.MEMOIR_PASSPHRASE || '';
468
- if (!passphrase || passphrase.length < 6) {
469
- spinner.stop();
470
- ({ passphrase } = await inquirer.prompt([{
471
- type: 'password',
472
- name: 'passphrase',
473
- message: '🔒 Encryption passphrase:',
474
- mask: '*',
475
- validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
476
- }]));
477
- }
450
+ const passphrase = await getPassphrase();
478
451
  spinner.start(chalk.gray('Deriving encryption key...'));
479
452
 
480
453
  encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
@@ -487,6 +460,19 @@ export async function pushCommand(options = {}) {
487
460
  const token = await createVerifyToken(passphrase);
488
461
  await fs.writeFile(path.join(encryptedDir, 'verify.enc'), token);
489
462
 
463
+ if (prior.source && !prior.encrypted) {
464
+ const verified = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-encryption-check-'));
465
+ try {
466
+ await decryptDirectory(encryptedDir, verified, passphrase);
467
+ const expected = (await listSafeFiles(stagingDir)).sort();
468
+ const actual = (await listSafeFiles(verified)).sort();
469
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) throw new Error('Encryption migration verification failed');
470
+ for (const rel of expected) {
471
+ if (!(await readSafeFile(stagingDir, rel)).equals(await readSafeFile(verified, rel))) throw new Error('Encryption migration content mismatch');
472
+ }
473
+ } finally { await fs.remove(verified); }
474
+ }
475
+
490
476
  uploadDir = encryptedDir;
491
477
  encrypted = true;
492
478
  }
@@ -494,9 +480,13 @@ export async function pushCommand(options = {}) {
494
480
  spinner.text = chalk.gray('Uploading to ' + (config.provider === 'git' ? 'GitHub' : 'local storage') + '...');
495
481
 
496
482
  if (config.provider === 'local' || config.provider.includes('local')) {
497
- await syncToLocal(config, uploadDir, spinner);
483
+ await syncToLocal(config, uploadDir, spinner, { verifiedReplacement: encrypted, lockHeld: options.localLockHeld });
498
484
  } else if (config.provider === 'git' || config.provider.includes('git')) {
499
- await syncToGit(config, uploadDir, spinner, preserveRemoteSession ? { preserve: ['session.json'] } : {});
485
+ await syncToGit(config, uploadDir, spinner, {
486
+ cloneDir: remoteCloneDir,
487
+ additive: !encrypted,
488
+ });
489
+ remoteCloneDir = null; // syncToGit removed it
500
490
  } else {
501
491
  spinner.fail(chalk.red(`Unknown provider: ${config.provider}`));
502
492
  return;
@@ -543,7 +533,7 @@ export async function pushCommand(options = {}) {
543
533
  let workspaceLine = '';
544
534
  if (workspaceManifest && workspaceManifest.projects.length > 0) {
545
535
  const gitCount = workspaceManifest.projects.filter(p => p.type === 'git' && p.gitRemote).length;
546
- const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile).length;
536
+ const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile || p.type === 'files').length;
547
537
  const parts = [];
548
538
  if (gitCount > 0) parts.push(`${gitCount} git`);
549
539
  if (bundleCount > 0) parts.push(`${bundleCount} bundled`);
@@ -567,11 +557,16 @@ export async function pushCommand(options = {}) {
567
557
  }
568
558
  } catch (error) {
569
559
  spinner.fail(chalk.red('Sync failed: ') + error.message);
560
+ throw error;
570
561
  } finally {
562
+ if (remotePlainDir) await fs.remove(remotePlainDir).catch(() => {});
571
563
  await fs.remove(stagingDir);
572
564
  // Clean up encrypted dir if it was created
573
565
  if (encryptedDir) {
574
566
  await fs.remove(encryptedDir).catch(() => {});
575
567
  }
568
+ if (remoteCloneDir) {
569
+ await fs.remove(remoteCloneDir).catch(() => {});
570
+ }
576
571
  }
577
572
  }
@@ -15,7 +15,7 @@ export async function recallCommand(query, options = {}) {
15
15
  }
16
16
  const limit = Math.max(1, Math.min(50, parseInt(options.limit, 10) || 10));
17
17
  const t0 = Date.now();
18
- const res = await searchMemories(q, { limit });
18
+ const res = await searchMemories(q, { limit, project: options.project });
19
19
  const ms = Date.now() - t0;
20
20
 
21
21
  if (options.json) {