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 +14 -0
- package/package.json +1 -1
- package/src/adapters/index.js +17 -30
- package/src/commands/push.js +26 -8
- package/src/commands/validate.js +407 -0
- package/src/session/state.js +51 -1
- package/src/utils/platform.js +47 -0
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.
|
|
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",
|
package/src/adapters/index.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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);
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
+
}
|
package/src/session/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|