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
@@ -6,13 +6,14 @@ import ora from 'ora';
6
6
  import boxen from 'boxen';
7
7
  import gradient from 'gradient-string';
8
8
  import inquirer from 'inquirer';
9
+ import { readSafeFile, writeSafeFile, relativeFile } from '../security/files.js';
9
10
  import { getConfig, autoSetup } from '../config.js';
10
11
  import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
11
12
  import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
12
13
  import { detectLocalHomeKey } from '../adapters/restore.js';
13
14
  import { restoreWorkspace } from '../workspace/tracker.js';
14
15
  import { getSession } from '../cloud/auth.js';
15
- import { unbundleToDir } from '../cloud/storage.js';
16
+ import { unbundleToDir, readBoundedResponse } from '../cloud/storage.js';
16
17
  import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
17
18
  import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
18
19
  import { withSessionLock } from '../session/lock.js';
@@ -34,7 +35,7 @@ export async function restoreCommand(options = {}) {
34
35
  const setupSpinner = ora({ text: chalk.gray('Setting up memoir automatically...'), spinner: 'dots' }).start();
35
36
  config = await autoSetup();
36
37
  if (config) {
37
- setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo}`));
38
+ setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo || config.localPath}`));
38
39
  } else {
39
40
  setupSpinner.fail(chalk.red('Could not detect GitHub username'));
40
41
  console.log('\n' + boxen(
@@ -75,8 +76,9 @@ export async function restoreCommand(options = {}) {
75
76
 
76
77
  // Verify passphrase first
77
78
  const verifyPath = path.join(stagingDir, 'verify.enc');
78
- let passphrase;
79
- for (let attempt = 0; attempt < 3; attempt++) {
79
+ let passphrase = process.env.MEMOIR_PASSPHRASE;
80
+ if (!passphrase && !process.stdin.isTTY) throw new Error('Set MEMOIR_PASSPHRASE to restore this encrypted backup.');
81
+ for (let attempt = 0; !passphrase && attempt < 3; attempt++) {
80
82
  const { pass } = await inquirer.prompt([{
81
83
  type: 'password',
82
84
  name: 'pass',
@@ -97,8 +99,7 @@ export async function restoreCommand(options = {}) {
97
99
  }
98
100
 
99
101
  if (!passphrase) {
100
- console.log(chalk.red('\n Too many failed attempts.'));
101
- return;
102
+ throw new Error('Too many failed passphrase attempts');
102
103
  }
103
104
 
104
105
  spinner.start(chalk.gray('Decrypting...'));
@@ -116,7 +117,7 @@ export async function restoreCommand(options = {}) {
116
117
  await fs.copy(decryptedDir, stagingDir, { overwrite: true });
117
118
  } catch (err) {
118
119
  spinner.fail(chalk.red('Decryption failed: ') + err.message);
119
- return;
120
+ throw err;
120
121
  } finally {
121
122
  await fs.remove(decryptedDir);
122
123
  }
@@ -135,8 +136,9 @@ export async function restoreCommand(options = {}) {
135
136
  // raw JSON.parse, so an old-schema file from a lagging machine gets
136
137
  // migrated up (or a too-new one safely degraded) BEFORE mergeSessions
137
138
  // ever touches it. Symmetric with the push-side fix in push.js.
138
- const rawRemote = JSON.parse(await fs.readFile(remoteSessionPath, 'utf8'));
139
- const { state: remote } = migrateSessionData(rawRemote);
139
+ const rawRemote = JSON.parse((await readSafeFile(stagingDir, 'session.json')).toString('utf8'));
140
+ const { state: remote, future } = migrateSessionData(rawRemote);
141
+ if (future) throw new Error('Backup uses a newer session schema. Upgrade Memoir first.');
140
142
  // Read+merge+write inside ONE lock, like every state.js mutator.
141
143
  // Reading outside the lock and locking only the write is a
142
144
  // check-then-act: a concurrent MCP memoir_note landing in the window
@@ -159,8 +161,8 @@ export async function restoreCommand(options = {}) {
159
161
  sessionMerged = true;
160
162
  sessionNewMachine = Object.keys(merged.machines || {}).length > beforeMachines;
161
163
  }
162
- } catch {
163
- // Best-effort — don't fail the restore over this
164
+ } catch (err) {
165
+ throw new Error('Session restore failed: ' + err.message);
164
166
  }
165
167
 
166
168
  if (sessionMerged) {
@@ -182,7 +184,7 @@ export async function restoreCommand(options = {}) {
182
184
  if (await fs.pathExists(handoffDir)) {
183
185
  const latestPath = path.join(handoffDir, 'latest.md');
184
186
  if (await fs.pathExists(latestPath)) {
185
- handoffContent = await fs.readFile(latestPath, 'utf8');
187
+ handoffContent = (await readSafeFile(handoffDir, 'latest.md')).toString('utf8');
186
188
  } else {
187
189
  // Find newest handoff
188
190
  const files = (await fs.readdir(handoffDir))
@@ -190,7 +192,7 @@ export async function restoreCommand(options = {}) {
190
192
  .sort()
191
193
  .reverse();
192
194
  if (files.length > 0) {
193
- handoffContent = await fs.readFile(path.join(handoffDir, files[0]), 'utf8');
195
+ handoffContent = (await readSafeFile(handoffDir, files[0])).toString('utf8');
194
196
  }
195
197
  }
196
198
  }
@@ -199,26 +201,10 @@ export async function restoreCommand(options = {}) {
199
201
  // Save locally
200
202
  const localHandoffDir = path.join(home, '.config', 'memoir', 'handoffs');
201
203
  await fs.ensureDir(localHandoffDir);
202
- await fs.writeFile(path.join(localHandoffDir, 'latest.md'), handoffContent);
203
-
204
- // Inject into Claude's home-level memory so it's always loaded
205
- // Use detection (reads what Claude actually created) with corrected fallback
206
- const claudeDir = path.join(home, '.claude');
207
- if (await fs.pathExists(claudeDir)) {
208
- let homeKey = detectLocalHomeKey(claudeDir);
209
- if (!homeKey) {
210
- // Fallback: compute key matching Claude's actual encoding
211
- if (process.platform === 'win32') {
212
- homeKey = home.replace(/\\/g, '-').replace(/:/g, '-');
213
- } else {
214
- homeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-');
215
- }
216
- }
217
- const claudeMemDir = path.join(claudeDir, 'projects', homeKey, 'memory');
218
- await fs.ensureDir(claudeMemDir);
219
- await fs.writeFile(path.join(claudeMemDir, 'handoff.md'), handoffContent);
220
- handoffInjected = true;
221
- }
204
+ await writeSafeFile(localHandoffDir, 'latest.md', handoffContent);
205
+
206
+ // Historical handoffs stay in the local archive. Project resume uses
207
+ // scoped session records; never inject an unscoped transcript globally.
222
208
 
223
209
  // Extract info for display — handles both old and new handoff formats
224
210
  const fromMatch = handoffContent.match(/\*\*From:\*\*\s*(.+)/) || handoffContent.match(/from \*\*(.+?)\*\*/);
@@ -239,7 +225,7 @@ export async function restoreCommand(options = {}) {
239
225
  let workspaceResults = null;
240
226
  try {
241
227
  spinner.start(chalk.gray('Checking workspace...'));
242
- workspaceResults = await restoreWorkspace(stagingDir, spinner, autoYes);
228
+ if (options.workspace === true) workspaceResults = await restoreWorkspace(stagingDir, spinner, autoYes);
243
229
  spinner.stop();
244
230
 
245
231
  if (workspaceResults) {
@@ -264,8 +250,8 @@ export async function restoreCommand(options = {}) {
264
250
  restored = true;
265
251
  }
266
252
  }
267
- } catch {
268
- // Workspace restore is best-effort
253
+ } catch (err) {
254
+ throw new Error('Workspace restore failed: ' + err.message);
269
255
  }
270
256
 
271
257
  if (restored) {
@@ -305,13 +291,14 @@ export async function restoreCommand(options = {}) {
305
291
 
306
292
  } catch (error) {
307
293
  spinner.fail(chalk.red('Restore failed: ') + error.message);
294
+ throw error;
308
295
  } finally {
309
296
  await fs.remove(stagingDir);
310
297
  }
311
298
  }
312
299
 
313
300
  async function restoreFromShare(options) {
314
- const shareToken = options.from;
301
+ const shareToken = encodeURIComponent(options.from);
315
302
 
316
303
  console.log();
317
304
  const spinner = ora({ text: chalk.gray('Fetching share link...'), spinner: 'dots' }).start();
@@ -389,7 +376,7 @@ async function restoreFromShare(options) {
389
376
  ? { 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON_KEY }
390
377
  : { 'apikey': SUPABASE_ANON_KEY };
391
378
 
392
- const dlRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${shareLink.backup_id}`, {
379
+ const dlRes = await fetch(`${SUPABASE_URL}/storage/v1/object/${STORAGE_BUCKET}/${relativeFile(shareLink.backup_id).split('/').map(encodeURIComponent).join('/')}`, {
393
380
  headers: authHeaders,
394
381
  });
395
382
 
@@ -397,7 +384,7 @@ async function restoreFromShare(options) {
397
384
  throw new Error(`Download failed: ${await dlRes.text()}`);
398
385
  }
399
386
 
400
- const gzipped = Buffer.from(await dlRes.arrayBuffer());
387
+ const gzipped = await readBoundedResponse(dlRes);
401
388
  await unbundleToDir(gzipped, stagingDir);
402
389
 
403
390
  // Decrypt — backup is always encrypted for shares
@@ -411,8 +398,9 @@ async function restoreFromShare(options) {
411
398
 
412
399
  // Verify passphrase
413
400
  const verifyPath = path.join(stagingDir, 'verify.enc');
414
- let passphrase;
415
- for (let attempt = 0; attempt < 3; attempt++) {
401
+ let passphrase = process.env.MEMOIR_PASSPHRASE;
402
+ if (!passphrase && !process.stdin.isTTY) throw new Error('Set MEMOIR_PASSPHRASE to restore this share');
403
+ for (let attempt = 0; !passphrase && attempt < 3; attempt++) {
416
404
  const { pass } = await inquirer.prompt([{
417
405
  type: 'password',
418
406
  name: 'pass',
@@ -433,8 +421,7 @@ async function restoreFromShare(options) {
433
421
  }
434
422
 
435
423
  if (!passphrase) {
436
- console.log(chalk.red('\n Too many failed attempts.'));
437
- return;
424
+ throw new Error('Too many failed passphrase attempts');
438
425
  }
439
426
 
440
427
  spinner.start(chalk.gray('Decrypting...'));
@@ -453,7 +440,7 @@ async function restoreFromShare(options) {
453
440
  restored = await restoreMemories(decryptedDir, spinner, onlyFilter, autoYes);
454
441
  } catch (err) {
455
442
  spinner.fail(chalk.red('Decryption failed: ') + err.message);
456
- return;
443
+ throw err;
457
444
  } finally {
458
445
  await fs.remove(decryptedDir);
459
446
  }
@@ -491,6 +478,7 @@ async function restoreFromShare(options) {
491
478
 
492
479
  } catch (error) {
493
480
  spinner.fail(chalk.red('Restore from share failed: ') + error.message);
481
+ throw error;
494
482
  } finally {
495
483
  await fs.remove(stagingDir);
496
484
  }
@@ -1,172 +1,23 @@
1
- import chalk from 'chalk';
2
1
  import fs from 'fs-extra';
3
2
  import path from 'path';
4
- import os from 'os';
5
- import ora from 'ora';
6
- import boxen from 'boxen';
7
- import gradient from 'gradient-string';
8
- import { getConfig } from '../config.js';
9
- import { execFileSync } from 'child_process';
10
-
11
- const home = os.homedir();
12
-
13
- // Fetch latest handoff from git backup
14
- async function fetchLatestHandoff(config, spinner) {
15
- const tmpDir = path.join(os.tmpdir(), `memoir-resume-${Date.now()}`);
16
- await fs.ensureDir(tmpDir);
17
-
18
- try {
19
- if (config.provider === 'git' || config.provider.includes('git')) {
20
- spinner.text = chalk.gray('Pulling latest handoff from GitHub...');
21
- execFileSync('git', ['clone', '--depth', '1', config.gitRepo, '.'], { cwd: tmpDir, stdio: 'ignore' });
22
- } else if (config.provider === 'local' || config.provider.includes('local')) {
23
- const resolvedSource = config.localPath.replace(/^~/, home);
24
- spinner.text = chalk.gray('Fetching handoff from local backup...');
25
- await fs.copy(resolvedSource, tmpDir);
26
- }
27
-
28
- const handoffDir = path.join(tmpDir, 'handoffs');
29
- if (!await fs.pathExists(handoffDir)) {
30
- return null;
31
- }
32
-
33
- // Find the newest handoff file
34
- const files = (await fs.readdir(handoffDir))
35
- .filter(f => f.endsWith('.md') && f !== 'latest.md')
36
- .sort()
37
- .reverse();
38
-
39
- if (files.length === 0) return null;
40
-
41
- const content = await fs.readFile(path.join(handoffDir, files[0]), 'utf8');
42
- return { filename: files[0], content };
43
- } finally {
44
- await fs.remove(tmpDir);
45
- }
46
- }
47
-
48
- // Inject handoff into a tool's context location
49
- async function injectHandoff(content, tool) {
50
- const targets = {
51
- claude: () => {
52
- // Write to Claude's project memory dir so it's auto-loaded
53
- const cwd = process.cwd();
54
- // Match Claude's actual path encoding: each separator → dash
55
- let cwdKey;
56
- if (process.platform === 'win32') {
57
- cwdKey = cwd.replace(/\\/g, '-').replace(/:/g, '-');
58
- } else {
59
- cwdKey = '-' + cwd.replace(/^\//, '').replace(/\//g, '-');
60
- }
61
- const memDir = path.join(home, '.claude', 'projects', cwdKey, 'memory');
62
- return path.join(memDir, 'handoff.md');
63
- },
64
- gemini: () => {
65
- return path.join(process.cwd(), 'GEMINI.md');
66
- },
67
- cursor: () => {
68
- return path.join(process.cwd(), '.cursor', 'rules', 'handoff.mdc');
69
- },
70
- codex: () => {
71
- return path.join(process.cwd(), 'AGENTS.md');
72
- }
73
- };
74
-
75
- const getTarget = targets[tool];
76
- if (!getTarget) {
77
- throw new Error(`Unknown tool: ${tool}. Supported: claude, gemini, cursor, codex`);
78
- }
79
-
80
- const targetPath = getTarget();
81
- await fs.ensureDir(path.dirname(targetPath));
82
-
83
- if (tool === 'gemini' && await fs.pathExists(targetPath)) {
84
- // Append to existing GEMINI.md
85
- const existing = await fs.readFile(targetPath, 'utf8');
86
- if (!existing.includes('# Session Handoff')) {
87
- await fs.writeFile(targetPath, existing + '\n\n' + content);
88
- } else {
89
- // Replace existing handoff section
90
- const before = existing.split('# Session Handoff')[0];
91
- await fs.writeFile(targetPath, before + content);
92
- }
93
- } else {
94
- await fs.writeFile(targetPath, content);
95
- }
96
-
97
- return targetPath;
98
- }
3
+ import { buildResumeBrief, formatResumeBrief } from '../session/brief.js';
4
+ import { BLOCK_START, BLOCK_END } from '../session/render.js';
5
+ import { injectInto } from '../session/inject.js';
6
+ import { safePath, writeSafeFile } from '../security/files.js';
99
7
 
100
8
  export async function resumeCommand(options = {}) {
101
- const config = await getConfig(options.profile);
102
-
103
- if (!config) {
104
- console.log('\n' + boxen(
105
- chalk.red('Not configured yet\n\n') +
106
- chalk.white('Run ') + chalk.cyan.bold('memoir init') + chalk.white(' to get started.'),
107
- { padding: 1, borderStyle: 'round', borderColor: 'red' }
108
- ) + '\n');
109
- return;
110
- }
111
-
112
- console.log();
113
- const spinner = ora({ text: chalk.gray('Fetching latest handoff...'), spinner: 'dots' }).start();
114
-
115
- // First check local cache
116
- const localLatest = path.join(home, '.config', 'memoir', 'handoffs', 'latest.md');
117
- let handoff;
118
-
119
- // Try remote first
120
- try {
121
- handoff = await fetchLatestHandoff(config, spinner);
122
- } catch (err) {
123
- spinner.warn(chalk.yellow(`Remote fetch failed: ${err.message}`));
124
- spinner.start();
125
- }
126
-
127
- // Fallback to local cache
128
- if (!handoff && await fs.pathExists(localLatest)) {
129
- handoff = { filename: 'latest.md', content: await fs.readFile(localLatest, 'utf8') };
130
- }
131
-
132
- if (!handoff) {
133
- spinner.fail(chalk.red('No handoffs found.'));
134
- console.log(chalk.gray('\n Run ') + chalk.cyan('memoir snapshot') + chalk.gray(' on another machine first.\n'));
135
- return;
136
- }
137
-
138
- // Save locally
139
- const localHandoffDir = path.join(home, '.config', 'memoir', 'handoffs');
140
- await fs.ensureDir(localHandoffDir);
141
- await fs.writeFile(path.join(localHandoffDir, 'latest.md'), handoff.content);
142
-
143
- spinner.stop();
144
-
145
- // Display the handoff
146
- console.log(boxen(
147
- gradient.pastel(' Session Handoff ') + '\n\n' +
148
- handoff.content
149
- .replace(/^---[\s\S]*?---\n/, '') // Strip YAML frontmatter for display
150
- .trim(),
151
- { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
152
- ));
153
-
154
- // Inject if requested
9
+ const project = options.project || process.env.MEMOIR_PROJECT_ROOT || process.cwd();
10
+ const brief = await buildResumeBrief(project);
11
+ const content = formatResumeBrief(brief);
12
+ console.log(content);
155
13
  if (options.inject) {
14
+ const targets = { claude: 'CLAUDE.md', codex: 'AGENTS.md', gemini: 'GEMINI.md', cursor: '.cursor/rules/memoir-resume.mdc' };
156
15
  const tool = options.to || 'claude';
157
- spinner.start(chalk.gray(`Injecting handoff for ${tool}...`));
158
- try {
159
- const targetPath = await injectHandoff(handoff.content, tool);
160
- spinner.stop();
161
- console.log('\n' + chalk.green(` Injected handoff ${targetPath}`));
162
- console.log(chalk.gray(` ${tool.charAt(0).toUpperCase() + tool.slice(1)} will read this on next session.\n`));
163
- } catch (err) {
164
- spinner.fail(chalk.red(`Inject failed: ${err.message}`));
165
- }
166
- } else {
167
- console.log('\n' + chalk.gray(' To inject into your AI tool:'));
168
- console.log(chalk.cyan(' memoir resume --inject') + chalk.gray(' (Claude)'));
169
- console.log(chalk.cyan(' memoir resume --inject --to gemini'));
170
- console.log(chalk.cyan(' memoir resume --inject --to cursor') + '\n');
16
+ if (!targets[tool]) throw new Error('Supported injection targets: claude, codex, gemini, cursor');
17
+ const target = await safePath(path.resolve(project), targets[tool], { createParents: true });
18
+ if (tool === 'cursor' && !await fs.pathExists(target)) await writeSafeFile(path.resolve(project), targets[tool], '---\ndescription: Memoir project handoff\nalwaysApply: true\n---\n');
19
+ await injectInto(target, [BLOCK_START, content, BLOCK_END].join('\n'));
20
+ console.log('\nUpdated the managed handoff in ' + target);
171
21
  }
22
+ return brief;
172
23
  }
@@ -19,6 +19,7 @@ import {
19
19
  addGoal,
20
20
  addNext,
21
21
  completeNext,
22
+ completeGoal,
22
23
  addNote,
23
24
  addQuestion,
24
25
  getMachineId,
@@ -46,25 +47,57 @@ async function refreshPinned() {
46
47
  return { state, rendered, updated };
47
48
  }
48
49
 
49
- export async function goalCommand(text) {
50
+ export async function goalCommand(text, options = {}) {
51
+ if (options.done) {
52
+ const state = await completeGoal(String(options.done).trim());
53
+ if (state.completed) {
54
+ await refreshPinned();
55
+ console.log('\n' + chalk.green(' ✓ Goal retired: ') + chalk.white(options.done) + '\n');
56
+ } else {
57
+ console.log('\n' + chalk.yellow(' No matching goal found.\n'));
58
+ }
59
+ return;
60
+ }
50
61
  if (!text || !String(text).trim()) {
51
- console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir goal "your current focus"\n'));
62
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir goal "your current focus"') + chalk.gray(' or ') + chalk.cyan('memoir goal --done "substring"\n'));
52
63
  return;
53
64
  }
54
- await addGoal(String(text).trim());
65
+ const state = await addGoal(String(text).trim());
55
66
  const { updated } = await refreshPinned();
56
67
  console.log('\n' + chalk.green(' ✓ Goal set: ') + chalk.white(text));
68
+ for (const g of state.replacedGoals || []) {
69
+ console.log(chalk.yellow(' ⚠ Goals list is full — replaced: ') + chalk.white(g.text));
70
+ }
57
71
  if (updated.length) console.log(chalk.gray(` Pinned to: ${updated.join(', ')}\n`));
58
72
  }
59
73
 
60
- export async function nextCommand(text) {
74
+ export async function nextCommand(text, options = {}) {
75
+ if (options.parked) {
76
+ const state = await readSession();
77
+ const parked = state.current.parked_actions || [];
78
+ if (!parked.length) {
79
+ console.log('\n' + chalk.gray(' Nothing parked.\n'));
80
+ return;
81
+ }
82
+ console.log('\n' + chalk.white.bold(` Parked next-actions (${parked.length}):`));
83
+ for (const p of parked) {
84
+ const when = (p.parked_at || '').slice(0, 10);
85
+ console.log(' ' + chalk.gray(`[ ] ${when ? when + ' ' : ''}`) + chalk.white(p.text));
86
+ }
87
+ console.log(chalk.gray('\n Re-add one with `memoir next "…"` to bring it back; `memoir done "…"` finishes it.\n'));
88
+ return;
89
+ }
61
90
  if (!text || !String(text).trim()) {
62
- console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir next "the next action"\n'));
91
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir next "the next action"') + chalk.gray(' or ') + chalk.cyan('memoir next --parked\n'));
63
92
  return;
64
93
  }
65
- await addNext(String(text).trim());
94
+ const state = await addNext(String(text).trim());
66
95
  await refreshPinned();
67
- console.log('\n' + chalk.green(' ✓ Added to next: ') + chalk.white(text) + '\n');
96
+ console.log('\n' + chalk.green(' ✓ Added to next: ') + chalk.white(text));
97
+ for (const p of state.justParked || []) {
98
+ console.log(chalk.yellow(' ⚠ Next list is full — parked (still open): ') + chalk.white(p.text));
99
+ }
100
+ console.log('');
68
101
  }
69
102
 
70
103
  export async function doneCommand(text) {
@@ -72,10 +105,11 @@ export async function doneCommand(text) {
72
105
  console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir done "substring of the action"\n'));
73
106
  return;
74
107
  }
108
+ const count = (st) => st.current.next_actions.length + (st.current.parked_actions || []).length;
75
109
  const before = await readSession();
76
110
  await completeNext(String(text).trim());
77
111
  const after = await readSession();
78
- const removed = before.current.next_actions.length - after.current.next_actions.length;
112
+ const removed = count(before) - count(after);
79
113
  if (removed > 0) {
80
114
  await refreshPinned();
81
115
  console.log('\n' + chalk.green(` ✓ Completed ${removed} action${removed !== 1 ? 's' : ''}\n`));
@@ -140,6 +174,14 @@ export async function sessionShowCommand() {
140
174
  body.push('');
141
175
  }
142
176
 
177
+ const parked = state.current.parked_actions || [];
178
+ if (parked.length) {
179
+ body.push(chalk.white.bold(` Parked (${parked.length}):`));
180
+ for (const p of parked.slice(0, 4)) body.push(' ' + chalk.gray('[ ] ') + chalk.gray(p.text.length > 100 ? p.text.slice(0, 100) + '…' : p.text));
181
+ if (parked.length > 4) body.push(chalk.gray(` …and ${parked.length - 4} more — memoir next --parked`));
182
+ body.push('');
183
+ }
184
+
143
185
  if (questions.length) {
144
186
  body.push(chalk.white.bold(' Open questions:'));
145
187
  for (const q of questions) body.push(' ' + chalk.yellow('? ') + chalk.white(q.text));
@@ -185,7 +227,7 @@ export async function sessionClearCommand() {
185
227
  // write was silently lost, and worse, could resurrect what was cleared.
186
228
  await withSessionLock(paths.sessionLock, async () => {
187
229
  const state = await readSession();
188
- state.current = { goals: [], next_actions: [], open_questions: [], decisions: [] };
230
+ state.current = { goals: [], next_actions: [], parked_actions: [], open_questions: [], decisions: [], completed_actions: [], completed_goals: [] };
189
231
  await writeSession(state);
190
232
  });
191
233
  await refreshPinned();
@@ -7,6 +7,7 @@ import boxen from 'boxen';
7
7
  import gradient from 'gradient-string';
8
8
  import { getConfig, getGeminiApiKey } from '../config.js';
9
9
  import { syncToLocal, syncToGit } from '../providers/index.js';
10
+ import { saveHandoff } from '../context/handoffs.js';
10
11
 
11
12
  const home = os.homedir();
12
13
 
@@ -337,7 +338,9 @@ export async function snapshotCommand(options = {}) {
337
338
  if (config.provider === 'local' || config.provider.includes('local')) {
338
339
  await syncToLocal(config, stagingDir, spinner);
339
340
  } else if (config.provider === 'git' || config.provider.includes('git')) {
340
- await syncToGit(config, stagingDir, spinner);
341
+ // additive: the staging dir holds ONE handoff file. A mirror push
342
+ // here would have deleted every other file in the backup.
343
+ await syncToGit(config, stagingDir, spinner, { additive: true });
341
344
  }
342
345
  } catch (err) {
343
346
  spinner.warn(chalk.yellow(`Push failed: ${err.message}. Saved locally.`));
@@ -346,13 +349,9 @@ export async function snapshotCommand(options = {}) {
346
349
  await fs.remove(stagingDir);
347
350
  }
348
351
 
349
- // Also save locally for immediate access
352
+ // Also save locally for immediate access (+ latest.md; dir is pruned)
350
353
  const localHandoffDir = path.join(home, '.config', 'memoir', 'handoffs');
351
- await fs.ensureDir(localHandoffDir);
352
- await fs.writeFile(path.join(localHandoffDir, filename), handoff);
353
-
354
- // Also save as "latest" for easy access
355
- await fs.writeFile(path.join(localHandoffDir, 'latest.md'), handoff);
354
+ await saveHandoff(handoff, { dirs: [localHandoffDir], filename });
356
355
 
357
356
  spinner.stop();
358
357
 
@@ -5,6 +5,23 @@ import boxen from 'boxen';
5
5
  import gradient from 'gradient-string';
6
6
  import { getConfig } from '../config.js';
7
7
  import { adapters } from '../adapters/index.js';
8
+ import { paths as eventPaths } from '../events/log.js';
9
+ import { summarizeEvents, formatSummaryLines } from '../events/summary.js';
10
+
11
+ const WINDOW_DAYS = 7;
12
+
13
+ // Best-effort usage block from events.jsonl (names and counts only — the
14
+ // log never holds content). Absent or unreadable log → no block.
15
+ async function usageLines() {
16
+ try {
17
+ if (!(await fs.pathExists(eventPaths.events))) return [];
18
+ const raw = await fs.readFile(eventPaths.events, 'utf8');
19
+ const s = summarizeEvents(raw, { sinceMs: WINDOW_DAYS * 24 * 60 * 60 * 1000 });
20
+ return formatSummaryLines(s, { days: WINDOW_DAYS });
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
8
25
 
9
26
  export async function statusCommand(options = {}) {
10
27
  const config = await getConfig(options.profile);
@@ -59,11 +76,16 @@ export async function statusCommand(options = {}) {
59
76
  ? '\n' + chalk.gray(` Also supports: ${notFound.join(', ')}`)
60
77
  : '';
61
78
 
79
+ const usage = await usageLines();
80
+ const usageBlock = usage.length
81
+ ? '\n\n' + chalk.bold.white(`Last ${WINDOW_DAYS} days`) + '\n' + usage.map((l) => chalk.gray(' ') + chalk.white(l)).join('\n')
82
+ : '';
83
+
62
84
  console.log(boxen(
63
85
  gradient.pastel(' memoir status ') + '\n\n' +
64
86
  configLine + '\n\n' +
65
87
  chalk.bold.white('AI Tools') + '\n' +
66
- lines.join('\n') + notFoundLine + '\n\n' +
88
+ lines.join('\n') + notFoundLine + usageBlock + '\n\n' +
67
89
  chalk.gray('─'.repeat(30)) + '\n' +
68
90
  summary,
69
91
  { padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
@@ -18,12 +18,18 @@ async function createCheckoutSession(session) {
18
18
  return data.url;
19
19
  }
20
20
 
21
+ // No shell. The URL comes from our own API, but `exec(\`open "${url}"\`)`
22
+ // is still a shell string built from data — flagged (correctly) by the
23
+ // agentscores.xyz scan as command injection, and one bad quote away from
24
+ // being one. execFile passes the URL as a single argument; on Windows
25
+ // rundll32's URL handler avoids cmd.exe parsing `&` inside the query string.
21
26
  function openUrl(url) {
22
- const { exec } = require('child_process');
27
+ const { execFile } = require('child_process');
23
28
  const platform = process.platform;
24
- if (platform === 'darwin') exec(`open "${url}"`);
25
- else if (platform === 'win32') exec(`start "" "${url}"`);
26
- else exec(`xdg-open "${url}"`);
29
+ const [cmd, args] = platform === 'darwin' ? ['open', [url]]
30
+ : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
31
+ : ['xdg-open', [url]];
32
+ try { execFile(cmd, args, () => {}); } catch {}
27
33
  }
28
34
 
29
35
  export async function upgradeCommand() {
@@ -105,11 +111,7 @@ export async function upgradeCommand() {
105
111
  const url = await createCheckoutSession(session);
106
112
  spinner.succeed(chalk.green(' Opening Stripe checkout...'));
107
113
 
108
- const { exec } = await import('child_process');
109
- const platform = process.platform;
110
- if (platform === 'darwin') exec(`open "${url}"`);
111
- else if (platform === 'win32') exec(`start "" "${url}"`);
112
- else exec(`xdg-open "${url}"`);
114
+ openUrl(url);
113
115
 
114
116
  console.log(
115
117
  '\n' + chalk.gray(' Complete payment in your browser.') + '\n' +
@@ -45,6 +45,9 @@ const DATE_FIELDS = ['created', 'updated', 'date', 'set_on', 'added', 'done_at',
45
45
 
46
46
  function parseScalar(raw) {
47
47
  let v = String(raw).trim();
48
+ if (v.startsWith('"') && v.endsWith('"')) {
49
+ try { return JSON.parse(v); } catch {}
50
+ }
48
51
  if (
49
52
  (v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
50
53
  (v.startsWith("'") && v.endsWith("'") && v.length >= 2)
@@ -5,7 +5,7 @@ import os from 'os';
5
5
  import ora from 'ora';
6
6
  import boxen from 'boxen';
7
7
  import inquirer from 'inquirer';
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
 
@@ -49,7 +49,7 @@ export async function viewCommand(options = {}) {
49
49
 
50
50
  try {
51
51
  if (config.provider === 'git') {
52
- execSync(`git clone --depth 1 ${config.gitRepo} .`, { cwd: stagingDir, stdio: 'ignore' });
52
+ execFileSync('git', ['clone', '--depth', '1', '--', config.gitRepo, '.'], { cwd: stagingDir, stdio: 'ignore' });
53
53
  } else {
54
54
  const resolvedSource = config.localPath.replace(/^~/, os.homedir());
55
55
  await fs.copy(resolvedSource, stagingDir);