memoir-cli 3.10.1 → 3.11.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.
package/bin/memoir.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import { autopushCommand } from '../src/commands/autopush.js';
35
35
  import { whyCommand } from '../src/commands/why.js';
36
36
  import { autoRefreshCommand } from '../src/commands/auto-refresh.js';
37
+ import { validateCommand } from '../src/commands/validate.js';
37
38
  import { hooksInstallCommand, hooksUninstallCommand, hooksStatusCommand } from '../src/commands/hooks.js';
38
39
  import { capture as track, telemetryCommand } from '../src/telemetry.js';
39
40
  import { createRequire } from 'module';
@@ -268,6 +269,19 @@ program
268
269
  } catch (err) { console.error(chalk.red('\n✖ Error:'), err.message); process.exit(1); }
269
270
  });
270
271
 
272
+ program
273
+ .command('validate [paths...]')
274
+ .description('Check session state and memory entry files against the memoir format spec (docs/SPEC.md)')
275
+ .option('--strict', 'Treat warnings as failures')
276
+ .action(async (paths, options) => {
277
+ try {
278
+ await validateCommand(paths || [], options);
279
+ } catch (err) {
280
+ console.error(chalk.red('\n✖ Error:'), err.message);
281
+ process.exit(1);
282
+ }
283
+ });
284
+
271
285
  program
272
286
  .command('doctor')
273
287
  .alias('diagnose')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.10.1",
3
+ "version": "3.11.0",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "Private, portable AI memory: synced across every coding tool and machine, end-to-end encrypted, free. One memory for Claude Code, Cursor, Copilot, Gemini + more — MCP-native, zero-knowledge, open source.",
6
6
  "main": "src/index.js",
@@ -4,12 +4,21 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import chalk from 'chalk';
6
6
  import { shouldIgnoreProject } from '../context/capture.js';
7
+ import { vscodeUserDir, vscodeGlobalStorage, xdgConfigDir } from '../utils/platform.js';
7
8
 
8
9
  const home = os.homedir();
9
10
 
10
11
  const isWin = process.platform === 'win32';
11
12
  const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
12
13
 
14
+ // VS Code-family config dirs — resolved per-OS (incl. Linux) via platform.js.
15
+ const cursorUserDir = vscodeUserDir('Cursor');
16
+ const windsurfUserDir = vscodeUserDir('Windsurf');
17
+ const clineStorageDir = vscodeGlobalStorage('saoudrizwan.claude-dev');
18
+ // Non-VSCode tools that store under AppData on Windows and XDG config on POSIX.
19
+ const copilotDir = isWin ? path.join(appData, 'GitHub Copilot') : path.join(xdgConfigDir(), 'github-copilot');
20
+ const zedDir = isWin ? path.join(appData, 'Zed') : path.join(xdgConfigDir(), 'zed');
21
+
13
22
  export const adapters = [
14
23
  {
15
24
  name: 'Gemini CLI',
@@ -78,13 +87,9 @@ export const adapters = [
78
87
  {
79
88
  name: 'Cursor',
80
89
  icon: '⚡',
81
- source: isWin
82
- ? path.join(appData, 'Cursor', 'User')
83
- : path.join(home, 'Library', 'Application Support', 'Cursor', 'User'),
90
+ source: cursorUserDir,
84
91
  filter: (src) => {
85
- const cursorDir = isWin
86
- ? path.join(appData, 'Cursor', 'User')
87
- : path.join(home, 'Library', 'Application Support', 'Cursor', 'User');
92
+ const cursorDir = cursorUserDir;
88
93
  const rel = path.relative(cursorDir, src);
89
94
  if (src === cursorDir) return true;
90
95
  const basename = path.basename(src);
@@ -100,13 +105,8 @@ export const adapters = [
100
105
  {
101
106
  name: 'GitHub Copilot',
102
107
  icon: '🐙',
103
- source: isWin
104
- ? path.join(appData, 'GitHub Copilot')
105
- : path.join(home, '.config', 'github-copilot'),
108
+ source: copilotDir,
106
109
  filter: (src) => {
107
- const copilotDir = isWin
108
- ? path.join(appData, 'GitHub Copilot')
109
- : path.join(home, '.config', 'github-copilot');
110
110
  if (src === copilotDir) return true;
111
111
  const basename = path.basename(src);
112
112
  // Only sync config — skip auth tokens and version files
@@ -117,13 +117,9 @@ export const adapters = [
117
117
  {
118
118
  name: 'Windsurf',
119
119
  icon: '🏄',
120
- source: isWin
121
- ? path.join(appData, 'Windsurf', 'User')
122
- : path.join(home, 'Library', 'Application Support', 'Windsurf', 'User'),
120
+ source: windsurfUserDir,
123
121
  filter: (src) => {
124
- const windsurfDir = isWin
125
- ? path.join(appData, 'Windsurf', 'User')
126
- : path.join(home, 'Library', 'Application Support', 'Windsurf', 'User');
122
+ const windsurfDir = windsurfUserDir;
127
123
  const rel = path.relative(windsurfDir, src);
128
124
  if (src === windsurfDir) return true;
129
125
  const basename = path.basename(src);
@@ -138,13 +134,8 @@ export const adapters = [
138
134
  {
139
135
  name: 'Zed',
140
136
  icon: '🔶',
141
- source: isWin
142
- ? path.join(appData, 'Zed')
143
- : path.join(home, '.config', 'zed'),
137
+ source: zedDir,
144
138
  filter: (src) => {
145
- const zedDir = isWin
146
- ? path.join(appData, 'Zed')
147
- : path.join(home, '.config', 'zed');
148
139
  const rel = path.relative(zedDir, src);
149
140
  if (src === zedDir) return true;
150
141
  const basename = path.basename(src);
@@ -163,13 +154,9 @@ export const adapters = [
163
154
  {
164
155
  name: 'Cline',
165
156
  icon: '🤖',
166
- source: isWin
167
- ? path.join(appData, 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev')
168
- : path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'),
157
+ source: clineStorageDir,
169
158
  filter: (src) => {
170
- const clineDir = isWin
171
- ? path.join(appData, 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev')
172
- : path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev');
159
+ const clineDir = clineStorageDir;
173
160
  const rel = path.relative(clineDir, src);
174
161
  if (src === clineDir) return true;
175
162
  const basename = path.basename(src);
@@ -371,6 +371,18 @@ export async function pushCommand(options = {}) {
371
371
  let shouldEncrypt = config.encrypt;
372
372
 
373
373
  if (shouldEncrypt === undefined) {
374
+ if (background || !process.stdin.isTTY) {
375
+ // First push with nobody to ask (the detached autopush hook, CI, a
376
+ // pipe). Any inquirer prompt here dies or hangs against ignored
377
+ // stdio. Encrypt if MEMOIR_PASSPHRASE makes that possible;
378
+ // otherwise push unencrypted THIS ONCE without persisting the
379
+ // choice — a backup beats no backup, and the next interactive push
380
+ // still gets the real question (default Yes).
381
+ shouldEncrypt = Boolean(process.env.MEMOIR_PASSPHRASE);
382
+ if (!shouldEncrypt) {
383
+ config.encrypt = undefined; // do not let the fallthrough persist "off"
384
+ }
385
+ } else {
374
386
  // First push since encryption was added — ask once and save preference
375
387
  spinner.stop();
376
388
  const { wantEncrypt } = await inquirer.prompt([{
@@ -380,6 +392,7 @@ export async function pushCommand(options = {}) {
380
392
  default: true
381
393
  }]);
382
394
  shouldEncrypt = wantEncrypt;
395
+ }
383
396
 
384
397
  // Save to config so we don't ask again
385
398
  try {
@@ -401,14 +414,19 @@ export async function pushCommand(options = {}) {
401
414
  }
402
415
 
403
416
  if (shouldEncrypt) {
404
- spinner.stop();
405
- const { passphrase } = await inquirer.prompt([{
406
- type: 'password',
407
- name: 'passphrase',
408
- message: '🔒 Encryption passphrase:',
409
- mask: '*',
410
- validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
411
- }]);
417
+ // Headless pushes can supply the passphrase via env; interactive
418
+ // pushes are asked as before.
419
+ let passphrase = process.env.MEMOIR_PASSPHRASE || '';
420
+ if (!passphrase || passphrase.length < 6) {
421
+ spinner.stop();
422
+ ({ passphrase } = await inquirer.prompt([{
423
+ type: 'password',
424
+ name: 'passphrase',
425
+ message: '🔒 Encryption passphrase:',
426
+ mask: '*',
427
+ validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
428
+ }]));
429
+ }
412
430
  spinner.start(chalk.gray('Deriving encryption key...'));
413
431
 
414
432
  encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
@@ -0,0 +1,407 @@
1
+ // `memoir validate` — structural conformance checks for the memoir format.
2
+ //
3
+ // docs/SPEC.md (v0.1 draft) is the normative text; schema/*.schema.json is
4
+ // the machine-readable mirror. The checks here are hand-rolled on purpose:
5
+ // no JSON Schema engine dependency for a format this small, and the few
6
+ // checks that matter (required fields, date sanity, tombstone invariants)
7
+ // stay readable as plain code.
8
+ //
9
+ // memoir validate validate the live session.json
10
+ // memoir validate <file.md> ... validate entry files
11
+ // memoir validate <dir> validate every *.md under dir
12
+ // (+ its session.json, if present)
13
+ // memoir validate --strict warnings count as failures
14
+ //
15
+ // Severity model:
16
+ // error — violates a MUST in SPEC.md; the file fails.
17
+ // warning — violates a SHOULD, or a legacy (pre-v0.1) dialect that
18
+ // readers must tolerate but writers must not emit.
19
+ // Exit code is 1 when any error occurred (or any warning, with --strict).
20
+
21
+ import chalk from 'chalk';
22
+ import fs from 'fs-extra';
23
+ import path from 'path';
24
+ import { paths } from '../session/state.js';
25
+ import { SCHEMA_VERSION } from '../session/migrations.js';
26
+
27
+ const ENTRY_TYPES = ['fact', 'preference', 'decision', 'lesson', 'goal', 'next_action'];
28
+
29
+ // Pre-v0.1 type vocabulary (SPEC.md Appendix A) — accepted with a warning.
30
+ const LEGACY_TYPES = {
31
+ user: 'preference',
32
+ feedback: 'lesson',
33
+ reference: 'fact',
34
+ project: null, // dossier — no atomic mapping; read as opaque legacy entry
35
+ };
36
+
37
+ // Frontmatter date-ish fields, checked for ISO 8601 shape when present.
38
+ const DATE_FIELDS = ['created', 'updated', 'date', 'set_on', 'added', 'done_at', 'hidden_at', 'last_fired'];
39
+
40
+ // ── Frontmatter parsing (restricted YAML subset per SPEC.md 3.1) ──
41
+ //
42
+ // Supports: scalar `key: value`, one level of nested mapping, and simple
43
+ // `- item` string lists. That is all the format allows in frontmatter, so
44
+ // that is all this parses. Not a general YAML parser and not meant to be.
45
+
46
+ function parseScalar(raw) {
47
+ let v = String(raw).trim();
48
+ if (
49
+ (v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
50
+ (v.startsWith("'") && v.endsWith("'") && v.length >= 2)
51
+ ) {
52
+ v = v.slice(1, -1);
53
+ }
54
+ if (v === 'true') return true;
55
+ if (v === 'false') return false;
56
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
57
+ return v;
58
+ }
59
+
60
+ export function parseFrontmatter(raw) {
61
+ const lines = String(raw).split(/\r?\n/);
62
+ if ((lines[0] || '').trim() !== '---') {
63
+ return { present: false, fields: {}, body: raw, error: null };
64
+ }
65
+ let end = -1;
66
+ for (let i = 1; i < lines.length; i++) {
67
+ if (lines[i].trim() === '---') { end = i; break; }
68
+ }
69
+ if (end === -1) {
70
+ return { present: true, fields: {}, body: raw, error: 'unterminated frontmatter (no closing ---)' };
71
+ }
72
+
73
+ const fields = {};
74
+ let openKey = null; // most recent top-level key whose value may nest
75
+ for (let i = 1; i < end; i++) {
76
+ const line = lines[i];
77
+ const trimmed = line.trim();
78
+ if (!trimmed || trimmed.startsWith('#')) continue;
79
+
80
+ if (/^\s/.test(line) && openKey) {
81
+ // One level of nesting under openKey: nested map entry or list item.
82
+ if (trimmed.startsWith('- ')) {
83
+ if (!Array.isArray(fields[openKey])) fields[openKey] = [];
84
+ fields[openKey].push(parseScalar(trimmed.slice(2)));
85
+ } else {
86
+ const m = trimmed.match(/^([^:]+):\s*(.*)$/);
87
+ if (m) {
88
+ if (typeof fields[openKey] !== 'object' || fields[openKey] === null || Array.isArray(fields[openKey])) {
89
+ fields[openKey] = {};
90
+ }
91
+ fields[openKey][m[1].trim()] = parseScalar(m[2]);
92
+ }
93
+ }
94
+ continue;
95
+ }
96
+
97
+ const m = line.match(/^([^:\s][^:]*):\s*(.*)$/);
98
+ if (!m) continue; // tolerate lines we don't understand — validation reports, not parsing, is the job
99
+ const key = m[1].trim();
100
+ if (m[2] === '') {
101
+ openKey = key;
102
+ fields[key] = '';
103
+ } else {
104
+ openKey = null;
105
+ fields[key] = parseScalar(m[2]);
106
+ }
107
+ }
108
+
109
+ return { present: true, fields, body: lines.slice(end + 1).join('\n'), error: null };
110
+ }
111
+
112
+ // ── Shared checks ────────────────────────────────────────────────
113
+
114
+ function isIsoDateString(v) {
115
+ return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) && !Number.isNaN(new Date(v).getTime());
116
+ }
117
+
118
+ // ── Entry file validation (SPEC.md section 3, entry.schema.json) ──
119
+
120
+ export function validateEntry(raw) {
121
+ const errors = [];
122
+ const warnings = [];
123
+ const { present, fields, body, error } = parseFrontmatter(raw);
124
+
125
+ if (!present) {
126
+ warnings.push('no frontmatter — legacy bare-markdown entry (SPEC.md Appendix A); readers treat as opaque, writers must not emit this');
127
+ return { errors, warnings };
128
+ }
129
+ if (error) {
130
+ errors.push(error);
131
+ return { errors, warnings };
132
+ }
133
+
134
+ // Resolve type across dialects: canonical top-level `type`, legacy `metadata.type`.
135
+ let type = fields.type;
136
+ if (type == null && fields.metadata && typeof fields.metadata === 'object' && fields.metadata.type != null) {
137
+ type = fields.metadata.type;
138
+ warnings.push('legacy dialect: type nested under metadata (canonical: top-level `type`)');
139
+ }
140
+
141
+ if (!fields.name || typeof fields.name !== 'string' || !String(fields.name).trim()) {
142
+ errors.push('missing required field: name');
143
+ }
144
+ if (!fields.description) {
145
+ warnings.push('missing description (SHOULD) — entry is invisible in indexes and pickers');
146
+ }
147
+
148
+ if (type == null) {
149
+ errors.push(`missing required field: type (one of: ${ENTRY_TYPES.join(', ')})`);
150
+ } else if (ENTRY_TYPES.includes(type)) {
151
+ validateCanonicalType(type, fields, body, errors, warnings);
152
+ } else if (type in LEGACY_TYPES) {
153
+ const mapped = LEGACY_TYPES[type];
154
+ warnings.push(`legacy type "${type}"${mapped ? ` (reads as ${mapped})` : ' (dossier — no atomic mapping)'} — canonical types: ${ENTRY_TYPES.join(', ')}`);
155
+ if (type === 'feedback' && !/\*\*How to apply:?\*\*/i.test(body)) {
156
+ warnings.push('legacy feedback entry without a **How to apply:** body section — lesson has no application rule');
157
+ }
158
+ } else {
159
+ errors.push(`unknown type "${type}" — canonical: ${ENTRY_TYPES.join(', ')}; legacy (read-only): ${Object.keys(LEGACY_TYPES).join(', ')}`);
160
+ }
161
+
162
+ for (const f of DATE_FIELDS) {
163
+ if (fields[f] != null && !isIsoDateString(fields[f])) {
164
+ errors.push(`field ${f} is not an ISO 8601 date: ${JSON.stringify(fields[f])}`);
165
+ }
166
+ }
167
+ if (fields.schema_version != null && (!Number.isInteger(fields.schema_version) || fields.schema_version < 1)) {
168
+ errors.push(`schema_version must be a positive integer, got ${JSON.stringify(fields.schema_version)}`);
169
+ }
170
+ if (Number.isInteger(fields.schema_version) && fields.schema_version > 1) {
171
+ warnings.push(`schema_version ${fields.schema_version} is newer than this build understands (v1) — forward-version rule applies: quarantine + degrade, never guess (SPEC.md section 6)`);
172
+ }
173
+
174
+ return { errors, warnings };
175
+ }
176
+
177
+ function validateCanonicalType(type, fields, body, errors, warnings) {
178
+ if (type === 'decision') {
179
+ if (!fields.why) warnings.push('decision without a why (required-encouraged) — a decision without a why is half a decision');
180
+ if (!fields.rejected) warnings.push('decision without a rejected alternative (required-encouraged)');
181
+ if (fields.hidden === true && !fields.hidden_at) {
182
+ errors.push('hidden: true without hidden_at — tombstones must carry when they were set (SPEC.md 5.3.1)');
183
+ }
184
+ } else if (type === 'lesson') {
185
+ if (!fields.trigger) {
186
+ errors.push('lesson missing required field: trigger — when does this lesson apply?');
187
+ }
188
+ if (!fields.how_to_apply) {
189
+ if (/\*\*How to apply:?\*\*/i.test(body)) {
190
+ warnings.push('how_to_apply lives in the body (**How to apply:** section) — accepted for legacy entries; canonical form is the frontmatter key');
191
+ } else {
192
+ errors.push('lesson missing required field: how_to_apply — without an application rule it is an anecdote, not a lesson');
193
+ }
194
+ }
195
+ if (fields.fired_count != null && (!Number.isInteger(fields.fired_count) || fields.fired_count < 0)) {
196
+ errors.push(`fired_count must be a non-negative integer, got ${JSON.stringify(fields.fired_count)}`);
197
+ }
198
+ } else if (type === 'next_action') {
199
+ if (!fields.added) {
200
+ errors.push('next_action missing required field: added — completion semantics (done_at vs added) are undefined without it');
201
+ }
202
+ if (isIsoDateString(fields.added) && isIsoDateString(fields.done_at) && new Date(fields.done_at) < new Date(fields.added)) {
203
+ warnings.push('done_at is earlier than added — this action was completed before it existed');
204
+ }
205
+ } else if (type === 'preference') {
206
+ if (fields.scope != null && !['global', 'project'].includes(fields.scope)) {
207
+ warnings.push(`preference scope should be "global" or "project", got ${JSON.stringify(fields.scope)}`);
208
+ }
209
+ }
210
+ // fact, goal: no extra required fields.
211
+ }
212
+
213
+ // ── Session file validation (SPEC.md section 4, session.schema.json) ──
214
+
215
+ function checkListItems(list, listName, dateField, errors, warnings) {
216
+ if (!Array.isArray(list)) {
217
+ errors.push(`current.${listName} must be an array`);
218
+ return;
219
+ }
220
+ list.forEach((item, i) => {
221
+ const at = `current.${listName}[${i}]`;
222
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
223
+ errors.push(`${at} must be an object`);
224
+ return;
225
+ }
226
+ if (typeof item.text !== 'string' || !item.text.trim()) {
227
+ errors.push(`${at} missing required field: text (the merge identity key)`);
228
+ }
229
+ if (item[dateField] != null && !isIsoDateString(item[dateField])) {
230
+ errors.push(`${at}.${dateField} is not an ISO 8601 date-time: ${JSON.stringify(item[dateField])}`);
231
+ }
232
+ if (item[dateField] == null) {
233
+ warnings.push(`${at} missing ${dateField} — merges treat a missing date as the epoch (always loses newest-wins)`);
234
+ }
235
+ });
236
+ }
237
+
238
+ export function validateSessionObject(obj) {
239
+ const errors = [];
240
+ const warnings = [];
241
+
242
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
243
+ errors.push('session must be a JSON object');
244
+ return { errors, warnings };
245
+ }
246
+
247
+ if (!Number.isInteger(obj.version) || obj.version < 1) {
248
+ errors.push(`version must be a positive integer, got ${JSON.stringify(obj.version)}`);
249
+ } else if (obj.version > SCHEMA_VERSION) {
250
+ warnings.push(`version ${obj.version} is newer than this build understands (v${SCHEMA_VERSION}) — forward-version rule applies: quarantine + degrade, never guess (SPEC.md section 6)`);
251
+ }
252
+
253
+ for (const f of ['created_at', 'updated_at']) {
254
+ if (obj[f] == null) errors.push(`missing required field: ${f}`);
255
+ else if (!isIsoDateString(obj[f])) errors.push(`${f} is not an ISO 8601 date-time: ${JSON.stringify(obj[f])}`);
256
+ }
257
+
258
+ if (!obj.machines || typeof obj.machines !== 'object' || Array.isArray(obj.machines)) {
259
+ errors.push('missing required field: machines (object of machine-uuid -> { label, last_seen })');
260
+ } else {
261
+ for (const [id, m] of Object.entries(obj.machines)) {
262
+ if (!m || typeof m !== 'object') { errors.push(`machines["${id}"] must be an object`); continue; }
263
+ if (typeof m.label !== 'string') errors.push(`machines["${id}"] missing required field: label`);
264
+ if (!isIsoDateString(m.last_seen)) errors.push(`machines["${id}"].last_seen is not an ISO 8601 date-time`);
265
+ }
266
+ }
267
+
268
+ const cur = obj.current;
269
+ if (!cur || typeof cur !== 'object' || Array.isArray(cur)) {
270
+ errors.push('missing required field: current');
271
+ } else {
272
+ checkListItems(cur.goals, 'goals', 'set_on', errors, warnings);
273
+ checkListItems(cur.next_actions, 'next_actions', 'added', errors, warnings);
274
+ checkListItems(cur.open_questions, 'open_questions', 'asked', errors, warnings);
275
+ checkListItems(cur.decisions, 'decisions', 'date', errors, warnings);
276
+ (Array.isArray(cur.decisions) ? cur.decisions : []).forEach((d, i) => {
277
+ if (d && d.hidden === true && !isIsoDateString(d.hidden_at)) {
278
+ errors.push(`current.decisions[${i}] hidden: true without a valid hidden_at (SPEC.md 5.3.1)`);
279
+ }
280
+ });
281
+ // completed_actions is optional (absent = empty), but when present its
282
+ // tombstones must be well-formed or the temporal merge rule breaks.
283
+ if (cur.completed_actions != null) {
284
+ if (!Array.isArray(cur.completed_actions)) {
285
+ errors.push('current.completed_actions must be an array when present');
286
+ } else {
287
+ cur.completed_actions.forEach((t, i) => {
288
+ const at = `current.completed_actions[${i}]`;
289
+ if (!t || typeof t !== 'object') { errors.push(`${at} must be an object`); return; }
290
+ if (typeof t.text !== 'string' || !t.text.trim()) errors.push(`${at} missing required field: text`);
291
+ if (!isIsoDateString(t.done_at)) errors.push(`${at} missing/invalid required field: done_at (the tombstone is meaningless without it)`);
292
+ });
293
+ }
294
+ }
295
+ }
296
+
297
+ if (!Array.isArray(obj.history)) {
298
+ errors.push('missing required field: history (array)');
299
+ } else {
300
+ obj.history.forEach((h, i) => {
301
+ if (!h || typeof h !== 'object') { errors.push(`history[${i}] must be an object`); return; }
302
+ if (!isIsoDateString(h.date)) errors.push(`history[${i}] missing/invalid required field: date`);
303
+ if (h.files_touched != null && !Array.isArray(h.files_touched)) errors.push(`history[${i}].files_touched must be an array`);
304
+ if (h.duration_min != null && typeof h.duration_min !== 'number') errors.push(`history[${i}].duration_min must be a number or null`);
305
+ });
306
+ }
307
+
308
+ return { errors, warnings };
309
+ }
310
+
311
+ // ── File collection ──────────────────────────────────────────────
312
+
313
+ async function collectTargets(args) {
314
+ // Returns [{ path, kind: 'entry' | 'session' }]. No args: the live session.json.
315
+ if (!args.length) {
316
+ return [{ path: paths.session, kind: 'session' }];
317
+ }
318
+ const targets = [];
319
+ for (const arg of args) {
320
+ const p = path.resolve(arg);
321
+ let stat;
322
+ try { stat = await fs.stat(p); }
323
+ catch { targets.push({ path: p, kind: 'missing' }); continue; }
324
+ if (stat.isDirectory()) {
325
+ const sessionPath = path.join(p, 'session.json');
326
+ if (await fs.pathExists(sessionPath)) targets.push({ path: sessionPath, kind: 'session' });
327
+ const stack = [p];
328
+ while (stack.length) {
329
+ const dir = stack.pop();
330
+ let entries = [];
331
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { continue; }
332
+ for (const e of entries) {
333
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
334
+ const full = path.join(dir, e.name);
335
+ if (e.isDirectory()) stack.push(full);
336
+ else if (e.name.endsWith('.md')) targets.push({ path: full, kind: 'entry' });
337
+ }
338
+ }
339
+ } else if (p.endsWith('.json')) {
340
+ targets.push({ path: p, kind: 'session' });
341
+ } else {
342
+ targets.push({ path: p, kind: 'entry' });
343
+ }
344
+ }
345
+ return targets;
346
+ }
347
+
348
+ // ── Command ──────────────────────────────────────────────────────
349
+
350
+ export async function validateCommand(args = [], options = {}) {
351
+ const targets = await collectTargets(args);
352
+
353
+ console.log('\n' + chalk.bold.white(' memoir validate') + chalk.gray(' — format v0.1 draft (docs/SPEC.md)') + '\n');
354
+
355
+ let checked = 0;
356
+ let failed = 0;
357
+ let totalWarnings = 0;
358
+
359
+ for (const target of targets) {
360
+ const rel = target.path.startsWith(process.cwd()) ? path.relative(process.cwd(), target.path) : target.path;
361
+
362
+ if (target.kind === 'missing') {
363
+ failed++;
364
+ console.log(chalk.red(' ✖ ') + chalk.white(rel));
365
+ console.log(chalk.red(' error ') + 'file not found');
366
+ continue;
367
+ }
368
+
369
+ checked++;
370
+ let result;
371
+ if (target.kind === 'session') {
372
+ let parsed;
373
+ try {
374
+ parsed = JSON.parse(await fs.readFile(target.path, 'utf8'));
375
+ } catch (err) {
376
+ result = { errors: [`unparseable JSON: ${err.message}`], warnings: [] };
377
+ }
378
+ if (!result) result = validateSessionObject(parsed);
379
+ } else {
380
+ let raw;
381
+ try {
382
+ raw = await fs.readFile(target.path, 'utf8');
383
+ } catch (err) {
384
+ result = { errors: [`unreadable: ${err.message}`], warnings: [] };
385
+ }
386
+ if (!result) result = validateEntry(raw);
387
+ }
388
+
389
+ const { errors, warnings } = result;
390
+ totalWarnings += warnings.length;
391
+ const fails = errors.length > 0 || (options.strict && warnings.length > 0);
392
+ if (fails) failed++;
393
+
394
+ const mark = fails ? chalk.red(' ✖ ') : warnings.length ? chalk.yellow(' ⚠ ') : chalk.green(' ✔ ');
395
+ console.log(mark + chalk.white(rel));
396
+ for (const e of errors) console.log(chalk.red(' error ') + e);
397
+ for (const w of warnings) console.log(chalk.yellow(' warning ') + w);
398
+ }
399
+
400
+ const parts = [`${checked} file${checked !== 1 ? 's' : ''} checked`];
401
+ parts.push(failed > 0 ? chalk.red(`${failed} failed`) : chalk.green('all passed'));
402
+ if (totalWarnings > 0) parts.push(chalk.yellow(`${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}`));
403
+ console.log('\n ' + parts.join(chalk.gray(' · ')) + '\n');
404
+
405
+ if (failed > 0) process.exitCode = 1;
406
+ return { checked, failed, warnings: totalWarnings };
407
+ }
@@ -33,6 +33,9 @@ export { SCHEMA_VERSION, emptySession };
33
33
  // Prevents unbounded growth of the live pinned block.
34
34
  const MAX_GOALS = 3;
35
35
  const MAX_NEXT = 8;
36
+ // Completion tombstones kept so merges can't resurrect finished actions.
37
+ // Must outlive every stale copy that might still carry the item.
38
+ const MAX_COMPLETED_TOMBSTONES = 50;
36
39
  const MAX_QUESTIONS = 5;
37
40
  const MAX_DECISIONS_RECENT = 10;
38
41
  const MAX_HISTORY = 30;
@@ -225,7 +228,20 @@ export async function completeNext(textOrIndex) {
225
228
  }
226
229
  const completed = idx >= 0;
227
230
  if (completed) {
228
- state.current.next_actions.splice(idx, 1);
231
+ // Removal alone is not enough: any merge with a copy that still holds
232
+ // this item (the push-side backup, another machine, a stale MCP-process
233
+ // write) re-unions it straight back — the "completed actions resurrect"
234
+ // bug. So completion also records a tombstone that merges consult.
235
+ // Temporal, not absolute like decisions' `hidden`: a re-add whose
236
+ // `added` postdates `done_at` is a deliberate revival and survives.
237
+ const [removed] = state.current.next_actions.splice(idx, 1);
238
+ const key = removed.text.trim().toLowerCase();
239
+ state.current.completed_actions = [
240
+ { text: removed.text, done_at: new Date().toISOString() },
241
+ ...(state.current.completed_actions || []).filter(
242
+ c => c && c.text && c.text.trim().toLowerCase() !== key
243
+ ),
244
+ ].slice(0, MAX_COMPLETED_TOMBSTONES);
229
245
  }
230
246
  await writeSession(state);
231
247
  // Only when something was actually completed — the event should mean
@@ -312,6 +328,25 @@ export function mergeSessions(local, remote) {
312
328
  history: mergeHistory(local.history, remote.history),
313
329
  };
314
330
 
331
+ // Completed-action tombstones beat the union above. unionByText can only
332
+ // union; it cannot represent "this used to exist and was finished," so a
333
+ // completed item surviving in ANY stale copy resurrected on every merge —
334
+ // the intermittent completeNext no-op observed in production (2026-08-03).
335
+ // Temporal on purpose: an item re-ADDED after its done_at is a deliberate
336
+ // revival and must survive, so tombstones only suppress copies whose
337
+ // `added` predates the completion.
338
+ const tombstones = unionTombstones(
339
+ local.current?.completed_actions,
340
+ remote.current?.completed_actions
341
+ );
342
+ merged.current.completed_actions = tombstones;
343
+ merged.current.next_actions = merged.current.next_actions.filter(a => {
344
+ const t = tombstones.find(
345
+ c => c.text.trim().toLowerCase() === a.text.trim().toLowerCase()
346
+ );
347
+ return !t || new Date(a.added || 0) > new Date(t.done_at);
348
+ });
349
+
315
350
  // machines: union last_seen per id (take the newer)
316
351
  for (const [id, entry] of Object.entries(remote.machines || {})) {
317
352
  const existing = merged.machines[id];
@@ -359,6 +394,21 @@ function unionByText(a = [], b = [], dateField, cap) {
359
394
  .slice(0, cap);
360
395
  }
361
396
 
397
+ function unionTombstones(a = [], b = []) {
398
+ const byText = new Map();
399
+ for (const item of [...(a || []), ...(b || [])]) {
400
+ if (!item || !item.text || !item.done_at) continue;
401
+ const key = item.text.trim().toLowerCase();
402
+ const existing = byText.get(key);
403
+ if (!existing || new Date(item.done_at) > new Date(existing.done_at)) {
404
+ byText.set(key, item);
405
+ }
406
+ }
407
+ return Array.from(byText.values())
408
+ .sort((x, y) => new Date(y.done_at) - new Date(x.done_at))
409
+ .slice(0, MAX_COMPLETED_TOMBSTONES);
410
+ }
411
+
362
412
  function mergeHistory(a = [], b = []) {
363
413
  const seen = new Set();
364
414
  const all = [...a, ...b].filter(h => h && h.date);
@@ -0,0 +1,47 @@
1
+ // Single source of truth for OS-specific config locations.
2
+ //
3
+ // Why this exists: before this module, every adapter branched on a single
4
+ // `isWin` flag — `isWin ? <windows> : <macOS>`. On Linux, `process.platform`
5
+ // is `'linux'`, so those ternaries silently fell through to the *macOS* path
6
+ // (`~/Library/Application Support/...`), which doesn't exist on Linux. memoir
7
+ // would then detect zero tools, sync nothing, and the user would churn without
8
+ // any error — the "silent-zero-memory activation cliff."
9
+ //
10
+ // Every function is parameterized by { platform, env, home } so the three OS
11
+ // branches can be unit-tested from any machine, not just the target OS. Runtime
12
+ // callers omit the options and get the live platform.
13
+
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+
17
+ const HOME = os.homedir();
18
+
19
+ // VS Code-family per-user config base: <root>/<App>/User
20
+ // win32 : %APPDATA%/<App>/User
21
+ // darwin : ~/Library/Application Support/<App>/User
22
+ // linux : $XDG_CONFIG_HOME (or ~/.config)/<App>/User
23
+ export function vscodeUserDir(appName, { platform = process.platform, env = process.env, home = HOME } = {}) {
24
+ if (platform === 'win32') {
25
+ const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
26
+ return path.join(appData, appName, 'User');
27
+ }
28
+ if (platform === 'darwin') {
29
+ return path.join(home, 'Library', 'Application Support', appName, 'User');
30
+ }
31
+ // linux + anything else POSIX-y
32
+ const xdg = env.XDG_CONFIG_HOME || path.join(home, '.config');
33
+ return path.join(xdg, appName, 'User');
34
+ }
35
+
36
+ // A VS Code extension's globalStorage dir (e.g. Cline lives under the base
37
+ // "Code" install, not its own app dir).
38
+ export function vscodeGlobalStorage(extId, opts = {}) {
39
+ return path.join(vscodeUserDir('Code', opts), 'globalStorage', extId);
40
+ }
41
+
42
+ // XDG-aware ~/.config base, for non-VSCode tools that already store there
43
+ // (zed, github-copilot). Exposed so callers don't re-hardcode ~/.config and
44
+ // drift from XDG_CONFIG_HOME.
45
+ export function xdgConfigDir({ env = process.env, home = HOME } = {}) {
46
+ return env.XDG_CONFIG_HOME || path.join(home, '.config');
47
+ }