memoir-cli 3.11.3 → 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 (76) hide show
  1. package/README.md +129 -124
  2. package/bin/memoir-work.js +9 -0
  3. package/bin/memoir.js +72 -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/auth.js +12 -15
  26. package/src/cloud/constants.js +6 -2
  27. package/src/cloud/storage.js +130 -93
  28. package/src/commands/activate.js +43 -9
  29. package/src/commands/cloud.js +56 -5
  30. package/src/commands/consolidate.js +49 -10
  31. package/src/commands/diff.js +2 -2
  32. package/src/commands/doctor.js +3 -3
  33. package/src/commands/forget.js +100 -0
  34. package/src/commands/push.js +164 -161
  35. package/src/commands/recall.js +42 -0
  36. package/src/commands/restore.js +32 -44
  37. package/src/commands/resume.js +15 -164
  38. package/src/commands/session.js +51 -9
  39. package/src/commands/snapshot.js +6 -7
  40. package/src/commands/status.js +23 -1
  41. package/src/commands/upgrade.js +13 -11
  42. package/src/commands/validate.js +16 -0
  43. package/src/commands/view.js +2 -2
  44. package/src/commands/why.js +4 -3
  45. package/src/config.js +9 -40
  46. package/src/context/capture.js +135 -33
  47. package/src/context/handoffs.js +72 -0
  48. package/src/events/summary.js +122 -0
  49. package/src/integrations/setup.js +88 -0
  50. package/src/mcp.js +151 -283
  51. package/src/memory/lexical-index.js +65 -0
  52. package/src/memory/repository.js +16 -0
  53. package/src/memory/scope.js +65 -0
  54. package/src/memory/search.js +598 -0
  55. package/src/memory/store.js +141 -0
  56. package/src/providers/index.js +182 -51
  57. package/src/providers/restore.js +5 -1
  58. package/src/security/encryption.js +34 -60
  59. package/src/security/files.js +155 -0
  60. package/src/session/brief.js +47 -0
  61. package/src/session/inject.js +12 -6
  62. package/src/session/lock.js +39 -118
  63. package/src/session/migrations.js +6 -0
  64. package/src/session/render.js +34 -4
  65. package/src/session/state.js +305 -34
  66. package/src/work/cli.js +64 -0
  67. package/src/work/errors.js +8 -0
  68. package/src/work/server.js +28 -0
  69. package/src/work/setup.js +96 -0
  70. package/src/work/store.js +340 -0
  71. package/src/work/ui/app.js +205 -0
  72. package/src/work/ui/index.html +30 -0
  73. package/src/work/ui/style.css +3 -0
  74. package/src/work/view.js +93 -0
  75. package/src/workspace/tracker.js +84 -332
  76. package/supabase/migrations/202609050001_backup_versions.sql +50 -0
@@ -1,3 +1,4 @@
1
+ import { repositoryState } from '../memory/repository.js';
1
2
  // Session state: the canonical source of truth for "what are we working on"
2
3
  // across sessions and machines. Rendered into CLAUDE.md (and other tools) as a
3
4
  // pinned block at the top, guaranteed to load.
@@ -9,6 +10,7 @@ import fs from 'fs-extra';
9
10
  import path from 'path';
10
11
  import os from 'os';
11
12
  import crypto from 'crypto';
13
+ import { projectIdentity, visibleMemory } from '../memory/scope.js';
12
14
  import { withSessionLock } from './lock.js';
13
15
  import { SCHEMA_VERSION, migrateSessionData, emptySession } from './migrations.js';
14
16
  // NOTE: events/log.js imports getMachineId FROM this module — this is a
@@ -33,12 +35,56 @@ export { SCHEMA_VERSION, emptySession };
33
35
  // Prevents unbounded growth of the live pinned block.
34
36
  const MAX_GOALS = 3;
35
37
  const MAX_NEXT = 8;
38
+ // Overflow from next_actions goes here instead of vanishing. `slice(-MAX_NEXT)`
39
+ // used to drop the oldest item with no warning, no event and no render hint:
40
+ // three of the author's live next-actions disappeared in one week (2026-09-03
41
+ // → 09-04) while the store sat at exactly 8. Parked items stay rendered and
42
+ // completable; only when THIS list overflows is anything dropped, and that
43
+ // emits an event.
44
+ const MAX_PARKED = Infinity;
36
45
  // Completion tombstones kept so merges can't resurrect finished actions.
37
46
  // Must outlive every stale copy that might still carry the item.
38
- const MAX_COMPLETED_TOMBSTONES = 50;
47
+ const MAX_COMPLETED_TOMBSTONES = Infinity;
39
48
  const MAX_QUESTIONS = 5;
40
49
  const MAX_DECISIONS_RECENT = 10;
41
- const MAX_HISTORY = 30;
50
+ const MAX_HISTORY = Infinity;
51
+
52
+ // ── Decision identity ────────────────────────────────────────────
53
+ //
54
+ // SPEC.md 5.1: a decision's identity is its normalized text. A PURGED
55
+ // tombstone (memoir forget --purge) has had that text redacted, so it
56
+ // carries `text_hash` = sha256(identity) instead and matches by hash.
57
+ // Both forms resolve to the same key here so unionByText/capDecisions
58
+ // treat "the original" and "the purged tombstone of the original" as one
59
+ // identity — that is what lets the tombstone keep suppressing copies of
60
+ // the un-purged text on replicas that never saw the purge.
61
+ export const PURGED_TEXT = '[purged]';
62
+
63
+ export function decisionIdentity(text) {
64
+ return String(text || '').trim().toLowerCase();
65
+ }
66
+
67
+ export function decisionHash(text) {
68
+ return crypto.createHash('sha256').update(decisionIdentity(text)).digest('hex');
69
+ }
70
+
71
+ function decisionKey(item) {
72
+ if (!item) return null;
73
+ if (item.text_hash) return 'sha256:' + item.text_hash + (item.project ? ':' + item.project : '');
74
+ if (!item.text) return null;
75
+ return 'sha256:' + decisionHash(item.text) + (item.project ? ':' + item.project : '');
76
+ }
77
+
78
+ // Cap decisions WITHOUT evicting tombstones. A plain `slice(0, cap)` after
79
+ // an unshift meant the 11th note pushed the oldest hidden decision off the
80
+ // list — and a dropped tombstone is a resurrection waiting for the next
81
+ // merge with any replica still holding the un-hidden copy. Tombstones and
82
+ // visible entries get separate budgets, same as unionByText below.
83
+ function capDecisions(list = [], cap = MAX_DECISIONS_RECENT) {
84
+ const visible = list.filter((d) => d && !d.hidden).slice(0, cap);
85
+ const tombstones = list.filter((d) => d && d.hidden);
86
+ return [...visible, ...tombstones];
87
+ }
42
88
 
43
89
  // ── Machine identity ─────────────────────────────────────────────
44
90
 
@@ -153,6 +199,7 @@ export async function readSession() {
153
199
  // critical section (see the mutators below and lock.js).
154
200
  export async function writeSession(state) {
155
201
  await fs.ensureDir(CONFIG_DIR);
202
+ partitionWorkingState(state);
156
203
  state.version = SCHEMA_VERSION;
157
204
  state.updated_at = new Date().toISOString();
158
205
  const tmp = `${SESSION_PATH}.tmp-${process.pid}`;
@@ -183,14 +230,23 @@ export async function addGoal(text) {
183
230
  return withSessionLock(SESSION_LOCK_PATH, async () => {
184
231
  const state = await readSession();
185
232
  const machineId = await touchMachine(state);
233
+ const key = decisionIdentity(text);
234
+ // Re-setting an existing goal moves it to the front; it is not a duplicate.
235
+ state.current.goals = (state.current.goals || []).filter((g) => decisionIdentity(g?.text) !== key || !visibleMemory(g));
186
236
  state.current.goals.unshift({
187
237
  text,
238
+ id: crypto.randomUUID(),
239
+ project: projectIdentity(),
188
240
  machine_id: machineId,
189
241
  set_on: new Date().toISOString(),
190
242
  });
191
- state.current.goals = state.current.goals.slice(0, MAX_GOALS);
243
+ // The cap still applies (a pinned block with ten goals is no focus at
244
+ // all) but a replaced goal is reported, never silently dropped.
245
+ const replaced = state.current.goals.slice(MAX_GOALS);
246
+
192
247
  await writeSession(state);
193
- await appendEvent('goal_set', {}); // no PII/content — count-and-type only
248
+ await appendEvent('goal_set', { replaced: replaced.length }); // no PII/content — count-and-type only
249
+ Object.defineProperty(state, 'replacedGoals', { value: replaced, enumerable: false });
194
250
  return state;
195
251
  });
196
252
  }
@@ -201,16 +257,73 @@ export async function addNext(text) {
201
257
  const machineId = await touchMachine(state);
202
258
  // Dedupe by text (case-insensitive)
203
259
  const normalized = text.trim().toLowerCase();
204
- const exists = state.current.next_actions.some(a => a.text.trim().toLowerCase() === normalized);
260
+ const exists = state.current.next_actions.some(a => a.text.trim().toLowerCase() === normalized && visibleMemory(a));
261
+ let parked = [];
205
262
  if (!exists) {
263
+ // Re-adding a parked item is "bring it back", not a duplicate.
264
+ state.current.parked_actions = (state.current.parked_actions || [])
265
+ .filter((a) => a?.text?.trim().toLowerCase() !== normalized || !visibleMemory(a));
206
266
  state.current.next_actions.push({
207
267
  text,
208
- machine_id: machineId,
268
+ id: crypto.randomUUID(),
269
+ project: projectIdentity(),
270
+ machine_id: machineId,
209
271
  added: new Date().toISOString(),
210
272
  });
211
- state.current.next_actions = state.current.next_actions.slice(-MAX_NEXT);
273
+ ({ live: state.current.next_actions, parked } = parkOverflow(state.current.next_actions));
274
+ if (parked.length) {
275
+ const merged = [...parked, ...(state.current.parked_actions || [])];
276
+ const dropped = merged.slice(MAX_PARKED);
277
+ state.current.parked_actions = merged.slice(0, MAX_PARKED);
278
+ await appendEvent('next_parked', { count: parked.length, dropped: dropped.length });
279
+ }
212
280
  }
213
281
  await writeSession(state);
282
+ // Non-enumerable: callers can tell the user what was parked, nothing
283
+ // serialises it.
284
+ Object.defineProperty(state, 'justParked', { value: parked, enumerable: false });
285
+ return state;
286
+ });
287
+ }
288
+
289
+ // Split a next_actions list into the MAX_NEXT newest (live) and the overflow
290
+ // (oldest first), stamping parked_at on the overflow. Pure; shared by
291
+ // addNext and mergeSessions so both agree on what "full" means.
292
+ function parkOverflow(list, cap = MAX_NEXT, now = new Date().toISOString()) {
293
+ if (list.length <= cap) return { live: list, parked: [] };
294
+ const overflow = list.slice(0, list.length - cap);
295
+ return {
296
+ live: list.slice(list.length - cap),
297
+ parked: overflow.map((a) => ({ ...a, parked_at: a.parked_at || now })),
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Retire a goal. Same shape as completeNext: remove it AND record a
303
+ * temporal tombstone, because a plain removal comes straight back on the
304
+ * next union-merge with any copy that still carries it (the push-side
305
+ * backup, another machine). A goal re-set after its done_at survives.
306
+ */
307
+ export async function completeGoal(match) {
308
+ return withSessionLock(SESSION_LOCK_PATH, async () => {
309
+ const state = await readSession();
310
+ await touchMachine(state);
311
+ const normalized = String(match).trim().toLowerCase();
312
+ state.current.goals = [...(state.current.goals || []), ...(state.current.archived_goals || [])];
313
+ state.current.archived_goals = [];
314
+ const idx = state.current.goals.findIndex(g => visibleMemory(g) && g?.text?.trim().toLowerCase().includes(normalized));
315
+ const completed = idx >= 0;
316
+ if (completed) {
317
+ const [removed] = state.current.goals.splice(idx, 1);
318
+ const key = removed.text.trim().toLowerCase();
319
+ state.current.completed_goals = [
320
+ { text: removed.text, project: removed.project, done_at: new Date().toISOString() },
321
+ ...(state.current.completed_goals || []).filter((c) => c && c.text && decisionKey(c) !== decisionKey(removed)),
322
+ ].slice(0, MAX_COMPLETED_TOMBSTONES);
323
+ }
324
+ await writeSession(state);
325
+ if (completed) await appendEvent('goal_completed', {});
326
+ Object.defineProperty(state, 'completed', { value: completed, enumerable: false });
214
327
  return state;
215
328
  });
216
329
  }
@@ -220,11 +333,17 @@ export async function completeNext(textOrIndex) {
220
333
  const state = await readSession();
221
334
  await touchMachine(state);
222
335
  let idx = -1;
336
+ let list = state.current.next_actions;
223
337
  if (typeof textOrIndex === 'number') {
224
- idx = textOrIndex;
338
+ idx = list.map((item, index) => ({ item, index })).filter(x => visibleMemory(x.item))[textOrIndex]?.index ?? -1;
225
339
  } else {
226
340
  const normalized = String(textOrIndex).trim().toLowerCase();
227
- idx = state.current.next_actions.findIndex(a => a.text.trim().toLowerCase().includes(normalized));
341
+ idx = list.findIndex(a => visibleMemory(a) && a.text.trim().toLowerCase().includes(normalized));
342
+ if (idx < 0) {
343
+ // Parked items are still real next-actions — finishing one must work.
344
+ list = state.current.parked_actions || [];
345
+ idx = list.findIndex(a => visibleMemory(a) && a?.text?.trim().toLowerCase().includes(normalized));
346
+ }
228
347
  }
229
348
  const completed = idx >= 0;
230
349
  if (completed) {
@@ -234,12 +353,12 @@ export async function completeNext(textOrIndex) {
234
353
  // bug. So completion also records a tombstone that merges consult.
235
354
  // Temporal, not absolute like decisions' `hidden`: a re-add whose
236
355
  // `added` postdates `done_at` is a deliberate revival and survives.
237
- const [removed] = state.current.next_actions.splice(idx, 1);
356
+ const [removed] = list.splice(idx, 1);
238
357
  const key = removed.text.trim().toLowerCase();
239
358
  state.current.completed_actions = [
240
- { text: removed.text, done_at: new Date().toISOString() },
359
+ { text: removed.text, project: removed.project, done_at: new Date().toISOString() },
241
360
  ...(state.current.completed_actions || []).filter(
242
- c => c && c.text && c.text.trim().toLowerCase() !== key
361
+ c => c && c.text && decisionKey(c) !== decisionKey(removed)
243
362
  ),
244
363
  ].slice(0, MAX_COMPLETED_TOMBSTONES);
245
364
  }
@@ -256,6 +375,8 @@ export async function addNote(text, opts = {}) {
256
375
  const state = await readSession();
257
376
  const machineId = await touchMachine(state);
258
377
  const decision = {
378
+ id: crypto.randomUUID(),
379
+ project: projectIdentity(opts.project),
259
380
  text,
260
381
  machine_id: machineId,
261
382
  date: new Date().toISOString(),
@@ -263,7 +384,6 @@ export async function addNote(text, opts = {}) {
263
384
  if (opts.why) decision.why = opts.why;
264
385
  if (opts.rejected) decision.rejected = opts.rejected;
265
386
  state.current.decisions.unshift(decision);
266
- state.current.decisions = state.current.decisions.slice(0, MAX_DECISIONS_RECENT);
267
387
  await writeSession(state);
268
388
  // Count/booleans only — never the decision text itself.
269
389
  await appendEvent('decision_captured', { has_why: !!opts.why, has_rejected: !!opts.rejected });
@@ -271,16 +391,76 @@ export async function addNote(text, opts = {}) {
271
391
  });
272
392
  }
273
393
 
394
+ /**
395
+ * Find visible decisions matching a query — substring on text/why/rejected,
396
+ * or an exact identity match. Pure; shared by `memoir forget` and the
397
+ * memoir_forget MCP tool so both agree on what "matches" means.
398
+ */
399
+ export function matchDecisions(state, query) {
400
+ const q = decisionIdentity(query);
401
+ if (!q) return [];
402
+ const decisions = allDecisions(state).filter((d) => d && d.text && visibleMemory(d));
403
+ const exact = decisions.filter((d) => decisionIdentity(d.text) === q);
404
+ if (exact.length) return exact;
405
+ return decisions.filter((d) =>
406
+ [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase().includes(q)
407
+ );
408
+ }
409
+
410
+ /**
411
+ * Forget a decision: set the SPEC.md 5.3.1 absolute tombstone
412
+ * (`hidden: true` + `hidden_at`) on the decision whose identity is `text`.
413
+ *
414
+ * With `purge`, the text/why/rejected are also redacted in place and the
415
+ * entry keeps only `text_hash` as its identity — for when the thing to
416
+ * forget is a leaked secret and hiding it from render is not enough. The
417
+ * hash still lets the tombstone suppress un-purged copies on other
418
+ * replicas at merge time (see unionByText).
419
+ *
420
+ * Deliberately NOT a delete: removal does not survive union-merge (the
421
+ * exact bug 3.10.2 fixed for next_actions). And there is no un-forget —
422
+ * `hidden` is monotonic by spec, which is why the CLI confirms first.
423
+ */
424
+ export async function hideDecision(text, { purge = false } = {}) {
425
+ return withSessionLock(SESSION_LOCK_PATH, async () => {
426
+ const state = await readSession();
427
+ await touchMachine(state);
428
+ const key = decisionIdentity(text);
429
+ state.current.decisions = allDecisions(state);
430
+ state.current.archived_decisions = [];
431
+ const idx = (state.current.decisions || []).findIndex(
432
+ (d) => d && d.text && visibleMemory(d) && decisionIdentity(d.text) === key
433
+ );
434
+ if (idx < 0) return { state, hidden: false };
435
+
436
+ const now = new Date().toISOString();
437
+ const d = state.current.decisions[idx];
438
+ const tomb = { ...d, hidden: true, hidden_at: now };
439
+ if (purge) {
440
+ tomb.text_hash = decisionHash(d.text);
441
+ tomb.text = PURGED_TEXT;
442
+ delete tomb.why;
443
+ delete tomb.rejected;
444
+ }
445
+ state.current.decisions[idx] = tomb;
446
+ await writeSession(state);
447
+ await appendEvent('decision_hidden', { purged: !!purge });
448
+ return { state, hidden: true, purged: !!purge };
449
+ });
450
+ }
451
+
274
452
  export async function addQuestion(text) {
275
453
  return withSessionLock(SESSION_LOCK_PATH, async () => {
276
454
  const state = await readSession();
277
455
  const machineId = await touchMachine(state);
278
456
  state.current.open_questions.push({
279
457
  text,
458
+ id: crypto.randomUUID(),
459
+ project: projectIdentity(),
280
460
  machine_id: machineId,
281
461
  asked: new Date().toISOString(),
282
462
  });
283
- state.current.open_questions = state.current.open_questions.slice(-MAX_QUESTIONS);
463
+
284
464
  await writeSession(state);
285
465
  return state;
286
466
  });
@@ -288,17 +468,32 @@ export async function addQuestion(text) {
288
468
 
289
469
  // Roll up the current state into a history entry. Use at session end / push.
290
470
  // Does not clear `current` — these are "the working set," not per-session scratch.
291
- export async function recordSessionEnd({ summary, filesTouched = [], durationMin = null } = {}) {
471
+ export async function recordSessionEnd({ summary, filesTouched = [], durationMin = null, sessionId = null, project } = {}) {
292
472
  return withSessionLock(SESSION_LOCK_PATH, async () => {
293
473
  const state = await readSession();
294
474
  const machineId = await touchMachine(state);
295
- state.history.unshift({
475
+ const entry = {
296
476
  date: new Date().toISOString(),
297
477
  machine_id: machineId,
478
+ project: projectIdentity(project),
298
479
  summary: summary || '',
299
480
  files_touched: filesTouched.slice(0, 20),
300
481
  duration_min: durationMin,
301
- });
482
+ };
483
+ const repo = repositoryState(project || process.env.MEMOIR_PROJECT_ROOT || process.cwd());
484
+ entry.repo_head = repo.head;
485
+ entry.branch = repo.branch;
486
+ entry.working_tree_dirty = repo.dirty;
487
+ if (sessionId) entry.session_id = sessionId;
488
+ // Autopush fires after every response, so one long session used to fill
489
+ // all five "Recent sessions" rows with itself. Same session → update its
490
+ // row in place (duration and files grow), don't add another.
491
+ const existing = sessionId ? state.history.findIndex((h) => h?.session_id === sessionId) : -1;
492
+ if (existing >= 0) {
493
+ entry.date = state.history[existing].date || entry.date;
494
+ state.history.splice(existing, 1);
495
+ }
496
+ state.history.unshift(entry);
302
497
  state.history = state.history.slice(0, MAX_HISTORY);
303
498
  await writeSession(state);
304
499
  return state;
@@ -320,14 +515,47 @@ export function mergeSessions(local, remote) {
320
515
  updated_at: latest(local.updated_at, remote.updated_at),
321
516
  machines: { ...remote.machines, ...local.machines }, // local wins for same machine
322
517
  current: {
323
- goals: unionByText(local.current?.goals, remote.current?.goals, 'set_on', MAX_GOALS),
324
- next_actions: unionByText(local.current?.next_actions, remote.current?.next_actions, 'added', MAX_NEXT),
325
- open_questions: unionByText(local.current?.open_questions, remote.current?.open_questions, 'asked', MAX_QUESTIONS),
326
- decisions: unionByText(local.current?.decisions, remote.current?.decisions, 'date', MAX_DECISIONS_RECENT),
518
+ goals: unionByText([...(local.current?.goals || []), ...(local.current?.archived_goals || [])], [...(remote.current?.goals || []), ...(remote.current?.archived_goals || [])], 'set_on', Infinity),
519
+ archived_goals: [],
520
+ // Live + parked from both sides pooled, then re-split below: the
521
+ // MAX_NEXT newest are live, the rest parked. A capped union here used
522
+ // to evict the oldest on merge just as silently as addNext did.
523
+ next_actions: unionByText(
524
+ [...(local.current?.next_actions || []), ...(local.current?.parked_actions || [])],
525
+ [...(remote.current?.next_actions || []), ...(remote.current?.parked_actions || [])],
526
+ 'added', Infinity),
527
+ parked_actions: [],
528
+ open_questions: unionByText([...(local.current?.open_questions || []), ...(local.current?.archived_questions || [])], [...(remote.current?.open_questions || []), ...(remote.current?.archived_questions || [])], 'asked', Infinity),
529
+ archived_questions: [],
530
+ decisions: unionByText(allDecisions(local), allDecisions(remote), 'date', Infinity),
531
+ archived_decisions: [],
327
532
  },
328
533
  history: mergeHistory(local.history, remote.history),
329
534
  };
330
535
 
536
+ // Fields this build does not know about pass through from the local copy
537
+ // instead of being dropped. `current` is rebuilt from known keys above, so
538
+ // a merge performed by an OLDER memoir silently erased anything newer —
539
+ // live proof: minutes after 3.13.0 added parked_actions and
540
+ // completed_goals, a Stop-hook push still running 3.12 rebuilt `current`
541
+ // without them and wrote that back locally, undoing two goal retirements
542
+ // and a parked item. Local wins over remote for unknown keys because an
543
+ // old build cannot merge what it cannot read.
544
+ const KNOWN_CURRENT = new Set(['goals', 'next_actions', 'parked_actions', 'open_questions', 'decisions', 'archived_decisions', 'archived_goals', 'archived_questions', 'completed_actions', 'completed_goals']);
545
+ for (const src of [remote.current || {}, local.current || {}]) {
546
+ for (const [k, v] of Object.entries(src)) {
547
+ if (!KNOWN_CURRENT.has(k)) merged.current[k] = v;
548
+ }
549
+ }
550
+
551
+ // Goal tombstones — same temporal rule as next_actions below.
552
+ const goalTombstones = unionTombstones(local.current?.completed_goals, remote.current?.completed_goals);
553
+ merged.current.completed_goals = goalTombstones;
554
+ merged.current.goals = merged.current.goals.filter((g) => {
555
+ const t = goalTombstones.find((c) => decisionKey(c) === decisionKey(g));
556
+ return !t || new Date(g.set_on || 0) > new Date(t.done_at);
557
+ });
558
+
331
559
  // Completed-action tombstones beat the union above. unionByText can only
332
560
  // union; it cannot represent "this used to exist and was finished," so a
333
561
  // completed item surviving in ANY stale copy resurrected on every merge —
@@ -342,10 +570,16 @@ export function mergeSessions(local, remote) {
342
570
  merged.current.completed_actions = tombstones;
343
571
  merged.current.next_actions = merged.current.next_actions.filter(a => {
344
572
  const t = tombstones.find(
345
- c => c.text.trim().toLowerCase() === a.text.trim().toLowerCase()
573
+ c => decisionKey(c) === decisionKey(a)
346
574
  );
347
575
  return !t || new Date(a.added || 0) > new Date(t.done_at);
348
576
  });
577
+ // unionByText returns newest-first; next_actions is stored oldest-first
578
+ // (render reverses). Re-split into live (newest MAX_NEXT) and parked.
579
+ const pooled = [...merged.current.next_actions].reverse();
580
+ const split = parkOverflow(pooled);
581
+ merged.current.next_actions = split.live.map((a) => { const { parked_at, ...rest } = a; return rest; });
582
+ merged.current.parked_actions = split.parked.reverse().slice(0, MAX_PARKED);
349
583
 
350
584
  // machines: union last_seen per id (take the newer)
351
585
  for (const [id, entry] of Object.entries(remote.machines || {})) {
@@ -355,20 +589,33 @@ export function mergeSessions(local, remote) {
355
589
  }
356
590
  }
357
591
 
592
+ partitionWorkingState(merged);
358
593
  return merged;
359
594
  }
360
595
 
361
596
  function unionByText(a = [], b = [], dateField, cap) {
362
597
  const byText = new Map();
598
+ // Identity is normalized text (SPEC 5.1). Keyed through decisionKey so a
599
+ // PURGED decision tombstone — text redacted, `text_hash` kept — lands on
600
+ // the same key as the un-purged copies it must keep suppressing. For
601
+ // goals/next_actions/questions (no purge concept) this is just a hash of
602
+ // the same normalized text and behaves exactly as before.
363
603
  for (const item of [...a, ...b]) {
364
- if (!item || !item.text) continue;
365
- const key = item.text.trim().toLowerCase();
604
+ const key = decisionKey(item);
605
+ if (!key) continue;
366
606
  const existing = byText.get(key);
367
607
  if (!existing || new Date(item[dateField] || 0) > new Date(existing[dateField] || 0)) {
368
608
  byText.set(key, item);
369
609
  }
370
610
  }
371
611
 
612
+ const stones = [...a, ...b].filter(i => i?.hidden);
613
+ for (const [key, item] of byText) {
614
+ if (item.project || item.hidden) continue;
615
+ const hash = item.text_hash || decisionHash(item.text);
616
+ if (stones.some(t => t.project && (t.text_hash || decisionHash(t.text)) === hash)) byText.delete(key);
617
+ }
618
+
372
619
  // A tombstone is STICKY: once any machine marks an entry hidden, the merged
373
620
  // result stays hidden, whatever the dates say.
374
621
  //
@@ -379,12 +626,17 @@ function unionByText(a = [], b = [], dateField, cap) {
379
626
  // the tombstoned copy doesn't even win the date comparison.) Suppression has
380
627
  // to be monotonic or it isn't suppression — you'd be re-hiding the same junk
381
628
  // on every machine forever.
629
+ //
630
+ // A PURGED tombstone wins outright — never let a date-winning un-purged
631
+ // copy carry the redacted text back into the merged result. Purge is
632
+ // "this text must leave the file"; the merged entry must be the purged one.
382
633
  for (const [key, winner] of byText) {
383
- if (winner.hidden) continue;
384
- const tombstone = [...a, ...b].find(
385
- (i) => i && i.text && i.text.trim().toLowerCase() === key && i.hidden
386
- );
387
- if (tombstone) {
634
+ const stones = [...a, ...b].filter((i) => i && i.hidden && decisionKey(i) === key);
635
+ if (!stones.length) continue;
636
+ const tombstone = stones.find((i) => i.text_hash) || stones[0];
637
+ if (tombstone.text_hash) {
638
+ byText.set(key, tombstone);
639
+ } else if (!winner.hidden) {
388
640
  byText.set(key, { ...winner, hidden: true, hidden_at: tombstone.hidden_at });
389
641
  }
390
642
  }
@@ -396,8 +648,8 @@ function unionByText(a = [], b = [], dateField, cap) {
396
648
  // not count against the visible budget.
397
649
  const all = Array.from(byText.values())
398
650
  .sort((x, y) => new Date(y[dateField] || 0) - new Date(x[dateField] || 0));
399
- const visible = all.filter((i) => !i.hidden).slice(0, cap);
400
- const tombstones = all.filter((i) => i.hidden).slice(0, cap);
651
+ const visible = all.filter((i) => !i.hidden).slice(0, cap === Infinity ? undefined : cap);
652
+ const tombstones = all.filter((i) => i.hidden);
401
653
  return [...visible, ...tombstones];
402
654
  }
403
655
 
@@ -405,7 +657,7 @@ function unionTombstones(a = [], b = []) {
405
657
  const byText = new Map();
406
658
  for (const item of [...(a || []), ...(b || [])]) {
407
659
  if (!item || !item.text || !item.done_at) continue;
408
- const key = item.text.trim().toLowerCase();
660
+ const key = decisionKey(item);
409
661
  const existing = byText.get(key);
410
662
  if (!existing || new Date(item.done_at) > new Date(existing.done_at)) {
411
663
  byText.set(key, item);
@@ -420,8 +672,10 @@ function mergeHistory(a = [], b = []) {
420
672
  const seen = new Set();
421
673
  const all = [...a, ...b].filter(h => h && h.date);
422
674
  // Dedupe by (date + machine_id + summary) — the three keys that make a session unique
423
- const unique = all.filter(h => {
424
- const key = `${h.date}|${h.machine_id}|${(h.summary || '').slice(0, 50)}`;
675
+ // Newest copy of a session_id wins (in-place updates change summary/duration).
676
+ const sorted = all.sort((x, y) => new Date(y.date) - new Date(x.date));
677
+ const unique = sorted.filter(h => {
678
+ const key = h.session_id ? `sid:${h.session_id}` : `${h.date}|${h.machine_id}|${(h.summary || '').slice(0, 50)}`;
425
679
  if (seen.has(key)) return false;
426
680
  seen.add(key);
427
681
  return true;
@@ -451,3 +705,20 @@ export const paths = {
451
705
  machineId: MACHINE_ID_PATH,
452
706
  sessionLock: SESSION_LOCK_PATH,
453
707
  };
708
+
709
+ export function allDecisions(state) {
710
+ return [...(state?.current?.decisions || []), ...(state?.current?.archived_decisions || [])];
711
+ }
712
+
713
+ function partitionWorkingState(state) {
714
+ state.current ||= {};
715
+ for (const [live, archive, date, cap] of [
716
+ ['decisions', 'archived_decisions', 'date', MAX_DECISIONS_RECENT],
717
+ ['goals', 'archived_goals', 'set_on', MAX_GOALS],
718
+ ['open_questions', 'archived_questions', 'asked', MAX_QUESTIONS],
719
+ ]) {
720
+ const all = unionByText(state.current[live], state.current[archive], date, Infinity);
721
+ state.current[live] = capDecisions(all, cap);
722
+ state.current[archive] = all.filter(item => !item.hidden).slice(cap);
723
+ }
724
+ }
@@ -0,0 +1,64 @@
1
+ import { Command } from 'commander';
2
+ import { recordWork, runWorkCheck, retractWork, refreshWork, formatWork } from './store.js';
3
+ import { setupWork } from './setup.js';
4
+ import { readSafeFile } from '../security/files.js';
5
+
6
+ export async function workCli(argv) {
7
+ const program = new Command('memoir work').description('Local project continuity for Codex and Cursor')
8
+ .option('--project <path>', 'Project directory', process.env.MEMOIR_PROJECT_ROOT || process.cwd());
9
+ const project = () => program.opts().project;
10
+ program.command('setup').option('--tools <names>', 'codex,cursor', 'codex,cursor').action(async options => {
11
+ console.log(JSON.stringify(await setupWork(project(), { tools: options.tools.split(',') }), null, 2));
12
+ });
13
+ program.command('resume').option('--json', 'Structured context').action(async options => {
14
+ const view = await refreshWork(project());
15
+ console.log(options.json ? JSON.stringify(view, null, 2) : formatWork(view));
16
+ });
17
+ program.command('view').description('Review and correct project memory in a local browser')
18
+ .option('--no-open', 'Print the local link without opening a browser').option('--port <number>', 'Local port; 0 chooses an available port', '0').action(async options => {
19
+ const { startWorkView } = await import('./view.js');
20
+ const view = await startWorkView(project(), { port: Number(options.port) });
21
+ console.log(`Memoir project view: ${view.url}\nOnly this computer. Keep this terminal open; press Ctrl+C to stop.`);
22
+ if (options.open) {
23
+ const { spawn } = await import('node:child_process');
24
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
25
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', view.url] : [view.url];
26
+ const child = spawn(command, args, { stdio:'ignore', shell:false });
27
+ child.on('error', () => console.error('Open the local link above in your browser.'));
28
+ }
29
+ const stop = async () => { await view.close(); process.exitCode = 0; };
30
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
31
+ });
32
+ program.command('record').option('--json <record>', 'Project record JSON').option('--file <path>', 'Project-relative JSON file; - reads stdin').action(async options => {
33
+ if (Boolean(options.json) === Boolean(options.file)) throw new Error('Provide exactly one of --json or --file.');
34
+ let raw = options.json;
35
+ if (options.file === '-') {
36
+ const chunks = []; let bytes = 0;
37
+ for await (const chunk of process.stdin) {
38
+ bytes += chunk.length;
39
+ if (bytes > 16384) throw new Error('Project record input exceeds 16 KiB. Nothing was saved.');
40
+ chunks.push(chunk);
41
+ }
42
+ raw = Buffer.concat(chunks).toString();
43
+ }
44
+ else if (options.file) raw = (await readSafeFile(project(), options.file, { maxBytes: 16384 })).toString();
45
+ if (Buffer.byteLength(raw) > 16384) throw new Error('Project record input exceeds 16 KiB. Nothing was saved.');
46
+ const record = await recordWork(project(), JSON.parse(raw));
47
+ await refreshWork(project());
48
+ console.log(JSON.stringify(record, null, 2));
49
+ });
50
+ program.command('check <id> [argv...]').requiredOption('--title <text>', 'What the check proves').requiredOption('--files <paths...>', 'All relevant source/test inputs')
51
+ .option('--environment <name>', 'local or external', 'local').option('--timeout <ms>', 'Time limit', '30000').action(async (id, command, options) => {
52
+ const result = await runWorkCheck(project(), { id, title: options.title, files: options.files, command, environment: options.environment, timeout_ms: Number(options.timeout) });
53
+ await refreshWork(project());
54
+ console.log(JSON.stringify(result, null, 2));
55
+ if (result.exit_code !== 0 || result.timed_out || !result.inputs_stable) process.exitCode = 1;
56
+ });
57
+ program.command('retract <id>').requiredOption('--revision <number>', 'Current record revision').option('--category <name>', 'record or check', 'record').action(async (id, options) => {
58
+ const result = await retractWork(project(), { id, expected_revision: Number(options.revision), category: options.category });
59
+ await refreshWork(project());
60
+ console.log(JSON.stringify(result));
61
+ });
62
+ program.exitOverride();
63
+ await program.parseAsync(argv, { from: 'user' });
64
+ }
@@ -0,0 +1,8 @@
1
+ // Parser and filesystem messages can contain snippets of damaged secret files.
2
+ // Only our fixed domain errors may pass through to the client.
3
+ export function workErrorMessage(error) {
4
+ if (error instanceof SyntaxError) return 'Invalid project handoff JSON. Original file was preserved; contents were not returned.';
5
+ if (error?.name === 'ZodError' || error instanceof TypeError) return 'Invalid project record or evidence. Check the schema; original data was preserved.';
6
+ if (error?.code && error.code !== 'ELOCKED') return 'Project operation failed. Check file access and command arguments locally; file contents were not returned.';
7
+ return error?.message || 'Project operation failed.';
8
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // Deliberately exposes only the connected project's continuation records.
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ import { workRoot, recordSchema, checkSchema, recordWork, retractWork, refreshWork, formatWork } from './store.js';
7
+ import { workErrorMessage } from './errors.js';
8
+
9
+ const project = await workRoot(process.env.MEMOIR_PROJECT_ROOT || process.cwd());
10
+ const server = new McpServer({ name: 'memoir-work', version: '1.0.0' });
11
+ const respond = handler => async args => {
12
+ try {
13
+ const value = await handler(args);
14
+ const view = await refreshWork(project);
15
+ return { content: [{ type: 'text', text: value ? JSON.stringify(value) : formatWork(view) }] };
16
+ } catch (error) {
17
+ // Never echo rejected input, which might contain credentials.
18
+ return { isError: true, content: [{ type: 'text', text: workErrorMessage(error) }] };
19
+ }
20
+ };
21
+ server.tool('memoir_work_resume', 'Read this project and branch: answered questions, decisions, next actions and checks with current input comparisons. Call before asking repeated questions or rerunning saved checks.', {}, respond(async () => null));
22
+ server.tool('memoir_work_record', 'Save a project-only goal, answer, decision or next action. Never save personal preferences or secrets. Read first; corrections require the current expected_revision. Source must identify the actual user statement or project evidence. This cannot claim a test passed.', { record: recordSchema }, respond(async ({ record }) => recordWork(project, record)));
23
+ // An MCP server runs with its own host privileges, not necessarily the coding
24
+ // client's terminal sandbox. Never turn this memory connection into a shell.
25
+ // Keep the old tool name to give existing clients a safe migration response.
26
+ server.tool('memoir_work_check', 'Command execution is disabled over MCP. Run memoir work check through the client’s normal terminal permission/sandbox route to capture execution evidence.', { check: checkSchema }, async () => ({ isError: true, content: [{ type: 'text', text: 'MCP command execution is disabled. Use the memoir work check CLI documented in project AGENTS.md through your normal terminal permissions. This memory connection does not grant shell access.' }] }));
27
+ server.tool('memoir_work_retract', 'Remove a mistaken record from the current handoff; its history remains locally for correction. Read its current revision first.', { id: z.string(), category: z.enum(['record', 'check']).default('record'), expected_revision: z.number().int() }, respond(async input => retractWork(project, input)));
28
+ await server.connect(new StdioServerTransport());