claude-mem-lite 2.69.0 → 2.70.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.69.0",
13
+ "version": "2.70.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.69.0",
3
+ "version": "2.70.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -209,14 +209,14 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
209
209
 
210
210
  ### MCP Tools (used automatically by Claude)
211
211
 
212
- As of v2.34.0, the server registers 17 tools in total but only the 6 **core**
212
+ As of v2.70.0, the server registers 20 tools in total but only the 9 **core**
213
213
  tools appear in `tools/list`. The 11 **hidden** tools remain callable at the
214
214
  protocol layer (`tools/call` by exact name still routes normally); they're
215
215
  omitted from the list response so Claude Code sessions don't load 11 extra
216
216
  tool schemas at startup. Hidden tools are the maintenance / admin / browser
217
217
  surface — reach them through the CLI column in the second table.
218
218
 
219
- **Core (6, exposed to Claude Code)**
219
+ **Core (9, exposed to Claude Code)**
220
220
 
221
221
  | Tool | Description |
222
222
  |------|-------------|
@@ -225,7 +225,10 @@ surface — reach them through the CLI column in the second table.
225
225
  | `mem_recall` | Recall observations related to a file. Use before editing to surface past bugfixes and context. |
226
226
  | `mem_timeline` | Browse observations chronologically around an anchor point. |
227
227
  | `mem_get` | Retrieve full details for specific observation IDs (includes importance and related_ids). |
228
- | `mem_save` | Manually save a memory/observation. |
228
+ | `mem_save` | Manually save a memory/observation. Accepts `closes_deferred` array for transactional closure of deferred work. |
229
+ | `mem_defer` | Mark work for a future session (v2.70+). First-class carry-forward signal, surfaced in SessionStart `### Deferred Work` block. |
230
+ | `mem_defer_list` | List open deferred items for the current project. |
231
+ | `mem_defer_drop` | Drop a deferred item without fixing it; requires a `reason` for the audit trail. |
229
232
 
230
233
  **Hidden-but-callable (11, CLI-routed)**
231
234
 
package/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'help']);
2
+ const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
3
3
  const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'release']);
4
4
 
5
5
  const cmd = process.argv[2];
package/hook-context.mjs CHANGED
@@ -407,26 +407,30 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
407
407
  handoffLines.push('');
408
408
  }
409
409
 
410
- // 5b. Deferred Work — project-level importance≥3 obs that survived prior
411
- // session boundaries. Independent of the per-session handoff: even when the
412
- // most recent /clear or /exit handoff has stale or meta-only `working_on`,
413
- // genuine carry-forward decisions stay surfaced. Capped at 3 to keep the
414
- // banner skim-able. Quiet-hooks does NOT suppress: the whole point is
415
- // visibility for cross-session continuity.
416
- const deferredObs = db.prepare(`
417
- SELECT id, type, title FROM observations
418
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0
419
- AND superseded_at IS NULL
420
- AND COALESCE(importance, 1) >= 3
421
- AND ${notLowSignalTitleClause('')}
422
- ORDER BY created_at_epoch DESC LIMIT 3
410
+ // 5b. Deferred Work — backed by deferred_work table (v2.70.0).
411
+ // Replaces the prior importance≥3 obs proxy. Items shown by per-project
412
+ // ordinal so user can refer to "处理1" / "handle item 1" naturally; D#<id>
413
+ // is the stable handle for tool-layer references (closes_deferred=[N]).
414
+ // Quiet-hooks does NOT suppress: cross-session continuity is the whole point.
415
+ const deferredItems = db.prepare(`
416
+ SELECT id, title, priority,
417
+ ROW_NUMBER() OVER (
418
+ ORDER BY priority DESC, created_at_epoch ASC
419
+ ) AS ordinal
420
+ FROM deferred_work
421
+ WHERE project = ? AND status = 'open'
422
+ ORDER BY priority DESC, created_at_epoch ASC
423
+ LIMIT 5
423
424
  `).all(project);
424
425
 
425
426
  const deferredLines = [];
426
- if (deferredObs.length > 0) {
427
+ if (deferredItems.length > 0) {
427
428
  deferredLines.push('### Deferred Work');
428
- for (const o of deferredObs) {
429
- deferredLines.push(`- ${typeIcon(o.type)} [${o.type}] ${truncate(o.title, 140)} (#${o.id})`);
429
+ for (const d of deferredItems) {
430
+ const pTag = d.priority === 3 ? '🔴' : d.priority === 1 ? '⚪' : '🟡';
431
+ deferredLines.push(
432
+ `${d.ordinal}. ${pTag} [P${d.priority}] ${truncate(d.title, 120)} (D#${d.id})`
433
+ );
430
434
  }
431
435
  deferredLines.push('');
432
436
  }
package/hook.mjs CHANGED
@@ -51,6 +51,7 @@ import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection,
51
51
  import { checkForUpdate } from './hook-update.mjs';
52
52
  import { handleLLMOptimize } from './hook-optimize.mjs';
53
53
  import { silentAutoAdopt, hasAutoAdoptMarker } from './adopt-cli.mjs';
54
+ import { emitV270UpgradeBanner } from './lib/upgrade-banner.mjs';
54
55
  // plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
55
56
  // from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
56
57
  // crash on static import. Degrade gracefully to no-op when the module is absent.
@@ -1073,6 +1074,13 @@ async function handleSessionStart() {
1073
1074
  // CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
1074
1075
  cleanupClaudeMdLegacyBlock();
1075
1076
 
1077
+ // v2.70.0 one-shot upgrade banner: notify users on first SessionStart per
1078
+ // project that the `### Deferred Work` block now reads from the
1079
+ // deferred_work table (was: high-importance observations in v2.69.x).
1080
+ // Idempotent via marker file; subsequent SessionStarts are silent.
1081
+ try { emitV270UpgradeBanner({ project, runtimeDir: RUNTIME_DIR }); }
1082
+ catch (e) { debugCatch(e, 'session-start-v270-banner'); }
1083
+
1076
1084
  // Pre-load TF-IDF vocabulary cache for this session (from DB, ~1ms)
1077
1085
  try { getVocabulary(db); } catch (e) { debugCatch(e, 'session-start-vocab'); }
1078
1086
 
@@ -0,0 +1,171 @@
1
+ // claude-mem-lite — deferred_work data layer
2
+ // Pure-data CRUD + ordinal resolver + transactional closure helper.
3
+ // Decoupled from observations table: different lifecycle, different scoring.
4
+
5
+ /**
6
+ * Insert a new open deferred_work row.
7
+ * @param {Database} db Opened DB
8
+ * @param {object} args
9
+ * @param {string} args.project Required project name
10
+ * @param {string} args.title Required one-line subject
11
+ * @param {number} [args.priority=2] 1=low, 2=normal, 3=urgent
12
+ * @param {string} [args.detail] Optional longer description
13
+ * @param {string[]} [args.files] Optional file paths
14
+ * @param {string} [args.source_session_id] Mem session id
15
+ * @param {number} [args.source_prompt_id] user_prompts.id
16
+ * @returns {{id: number}} Inserted row id
17
+ */
18
+ export function insertDeferred(db, args) {
19
+ const { project, title, priority = 2, detail = null, files = null,
20
+ source_session_id = null, source_prompt_id = null } = args;
21
+ // source_session_id / source_prompt_id: forward-compat for v2.71+ defer-detector
22
+ // hook (anchor a deferred item to the originating prompt). v1 inserts NULL.
23
+ if (!project || typeof project !== 'string') throw new Error('project required');
24
+ if (!title || typeof title !== 'string') throw new Error('title required');
25
+ if (![1, 2, 3].includes(priority)) throw new Error('priority must be 1, 2, or 3');
26
+ const stmt = db.prepare(`
27
+ INSERT INTO deferred_work
28
+ (project, title, detail, priority, status, created_at_epoch,
29
+ source_session_id, source_prompt_id, files)
30
+ VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?)
31
+ `);
32
+ const r = stmt.run(
33
+ project, title, detail, priority, Date.now(),
34
+ source_session_id, source_prompt_id,
35
+ files ? JSON.stringify(files) : null,
36
+ );
37
+ return { id: Number(r.lastInsertRowid) };
38
+ }
39
+
40
+ /**
41
+ * List open items in a project with computed per-project ordinal.
42
+ * Ordinal is dynamic — recomputed each call by ROW_NUMBER over open rows
43
+ * sorted (priority DESC, created_at_epoch ASC). When item-1 closes, item-2
44
+ * becomes the new item-1.
45
+ * @param {Database} db
46
+ * @param {string} project
47
+ * @param {number} [limit=10]
48
+ * @returns {Array<{id, project, title, detail, priority, status, created_at_epoch, ordinal}>}
49
+ */
50
+ export function listOpenWithOrdinal(db, project, limit = 10) {
51
+ return db.prepare(`
52
+ SELECT id, project, title, detail, priority, status, created_at_epoch,
53
+ ROW_NUMBER() OVER (ORDER BY priority DESC, created_at_epoch ASC) AS ordinal
54
+ FROM deferred_work
55
+ WHERE project = ? AND status = 'open'
56
+ ORDER BY priority DESC, created_at_epoch ASC
57
+ LIMIT ?
58
+ `).all(project, limit);
59
+ }
60
+
61
+ /**
62
+ * Set status='dropped' with a non-empty reason. No-op when status is not 'open'.
63
+ * @returns {{changed: number}} 1 if updated, 0 if not found or not open.
64
+ */
65
+ export function dropDeferred(db, id, reason) {
66
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
67
+ throw new Error('drop reason required (non-empty string)');
68
+ }
69
+ const r = db.prepare(`
70
+ UPDATE deferred_work
71
+ SET status='dropped', closed_at_epoch=?, drop_reason=?
72
+ WHERE id=? AND status='open'
73
+ `).run(Date.now(), reason.trim(), id);
74
+ return { changed: r.changes };
75
+ }
76
+
77
+ /**
78
+ * Resolve mixed ordinal (int) + raw-id ("D#<n>") tokens to real deferred_work
79
+ * ids, validated against caller project + status='open'.
80
+ *
81
+ * - bare integer N → ordinal-within-project (uses same ROW_NUMBER as listOpenWithOrdinal)
82
+ * - "D#<n>" string → raw deferred_work.id; must belong to caller project AND be open
83
+ *
84
+ * @param {Database} db
85
+ * @param {string} project Caller project (FK guard)
86
+ * @param {Array<number|string>} tokens Mixed input
87
+ * @returns {number[]} Real deferred_work ids in input order
88
+ * @throws {Error} On unresolvable input — error message names the offending token
89
+ */
90
+ export function resolveDeferredIds(db, project, tokens) {
91
+ if (!Array.isArray(tokens)) throw new Error('tokens must be an array');
92
+ // Pre-load open list once for ordinal resolution (ROW_NUMBER snapshot stable
93
+ // within this call so [1, 2] resolves consistently).
94
+ const open = db.prepare(`
95
+ SELECT id, ROW_NUMBER() OVER (ORDER BY priority DESC, created_at_epoch ASC) AS ordinal
96
+ FROM deferred_work
97
+ WHERE project = ? AND status = 'open'
98
+ `).all(project);
99
+ const ordinalToId = new Map(open.map(r => [r.ordinal, r.id]));
100
+
101
+ const getRow = db.prepare(`SELECT id, project, status FROM deferred_work WHERE id = ?`);
102
+ const seen = new Set();
103
+ const resolved = [];
104
+
105
+ for (const t of tokens) {
106
+ let id;
107
+ if (Number.isInteger(t)) {
108
+ id = ordinalToId.get(t);
109
+ if (id === undefined) {
110
+ throw new Error(`ordinal ${t} has no corresponding open deferred item in project "${project}" (open count: ${open.length})`);
111
+ }
112
+ } else if (typeof t === 'string') {
113
+ const m = /^D#(\d+)$/.exec(t.trim());
114
+ if (!m) throw new Error(`invalid token "${t}" — expected D#N or integer ordinal`);
115
+ id = parseInt(m[1], 10);
116
+ const row = getRow.get(id);
117
+ if (!row) throw new Error(`D#${id} not found`);
118
+ if (row.project !== project) {
119
+ throw new Error(`D#${id} belongs to project "${row.project}", not "${project}"`);
120
+ }
121
+ if (row.status !== 'open') {
122
+ throw new Error(`D#${id} status is "${row.status}", cannot close (only 'open' items)`);
123
+ }
124
+ } else {
125
+ throw new Error(`invalid token type ${typeof t} — expected D#N or integer ordinal`);
126
+ }
127
+ if (seen.has(id)) throw new Error(`duplicate token resolves to id ${id}`);
128
+ seen.add(id);
129
+ resolved.push(id);
130
+ }
131
+ return resolved;
132
+ }
133
+
134
+ /**
135
+ * Close a set of deferred items by id, all-or-nothing.
136
+ *
137
+ * Wraps the UPDATE loop in an internal transaction so that any per-row failure
138
+ * rolls back prior rows. better-sqlite3's `.transaction()` composes with an
139
+ * outer caller-managed transaction via SAVEPOINT — Task 5's wider closure flow
140
+ * (obs INSERT + closeDeferredItems) wraps both calls in one outer transaction
141
+ * to guarantee atomicity across the obs row and the deferred-work UPDATEs.
142
+ *
143
+ * @param {Database} db
144
+ * @param {number[]} ids Already-resolved real ids (use resolveDeferredIds first)
145
+ * @param {number} closingObsId observations.id that proves closure
146
+ * @throws {Error} If any id is not currently open (lookup-based safety net)
147
+ */
148
+ export function closeDeferredItems(db, ids, closingObsId) {
149
+ if (!Array.isArray(ids) || ids.length === 0) return;
150
+ if (!Number.isInteger(closingObsId) || closingObsId <= 0) {
151
+ throw new Error('closingObsId must be a positive integer');
152
+ }
153
+ // Defense-in-depth: even if caller already validated via resolveDeferredIds,
154
+ // re-check status here (caller may have done resolution earlier in the same
155
+ // transaction without holding a lock).
156
+ const stmt = db.prepare(`
157
+ UPDATE deferred_work
158
+ SET status='done', closed_at_epoch=?, closed_by_obs_id=?
159
+ WHERE id=? AND status='open'
160
+ `);
161
+ const now = Date.now();
162
+ const tx = db.transaction((idList) => {
163
+ for (const id of idList) {
164
+ const r = stmt.run(now, closingObsId, id);
165
+ if (r.changes !== 1) {
166
+ throw new Error(`closeDeferredItems: id ${id} was not in 'open' status (changes=${r.changes})`);
167
+ }
168
+ }
169
+ });
170
+ tx(ids);
171
+ }
package/lib/git-state.mjs CHANGED
@@ -6,10 +6,25 @@ import { execFileSync } from 'child_process';
6
6
 
7
7
  const GIT_TIMEOUT_MS = 1500;
8
8
 
9
+ // Strip inherited GIT_* env so child `git` operates on the requested `cwd`
10
+ // rather than a parent process's repo. Required when readGitState is called
11
+ // from contexts where GIT_DIR/GIT_INDEX_FILE/GIT_WORK_TREE/GIT_PREFIX leak in:
12
+ // pre-commit hooks running tests, hooks invoked under `git commit`, etc.
13
+ // Without this, headSha and `changed` reflect the parent's repo, not cwd's.
14
+ function buildCleanEnv() {
15
+ const env = { ...process.env };
16
+ delete env.GIT_DIR;
17
+ delete env.GIT_INDEX_FILE;
18
+ delete env.GIT_WORK_TREE;
19
+ delete env.GIT_PREFIX;
20
+ return env;
21
+ }
22
+
9
23
  function run(cmd, args, { cwd } = {}) {
10
24
  try {
11
25
  return execFileSync(cmd, args, {
12
26
  cwd,
27
+ env: buildCleanEnv(),
13
28
  encoding: 'utf8',
14
29
  timeout: GIT_TIMEOUT_MS,
15
30
  // Suppress git's own stderr noise (e.g. "fatal: not a git repository").
@@ -0,0 +1,31 @@
1
+ // One-shot v2.70.0 upgrade banner.
2
+ // Split out of hook.mjs because hook.mjs has module-level side effects
3
+ // (notably `if (!event) process.exit(0)` at top level) that abort vitest
4
+ // workers if imported directly from a test. See test
5
+ // tests/hook-upgrade-banner.test.mjs.
6
+
7
+ import { writeFileSync, existsSync } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ /**
11
+ * One-shot stderr banner on first SessionStart after v2.70.0 upgrade.
12
+ * Notifies users that the `### Deferred Work` block now reads from the
13
+ * deferred_work table (not high-importance observations as in v2.69.x).
14
+ * Idempotent via a marker file in `runtimeDir`; subsequent calls in the
15
+ * same project are silent.
16
+ *
17
+ * @param {object} args
18
+ * @param {string} args.project Project name (used in banner + marker filename).
19
+ * @param {string} args.runtimeDir RUNTIME_DIR (test override; production passes hook-shared.RUNTIME_DIR).
20
+ */
21
+ export function emitV270UpgradeBanner({ project, runtimeDir }) {
22
+ const marker = join(runtimeDir, `.deferred-block-migrated-${project}`);
23
+ if (existsSync(marker)) return;
24
+ process.stderr.write(
25
+ `[mem] v2.70.0 upgrade notice (project "${project}"): Deferred Work block now ` +
26
+ `backed by deferred_work table. To keep an obs visible there, run ` +
27
+ `\`claude-mem-lite defer add "<title>" --priority 3\`. ` +
28
+ `Pin to 2.69.x to revert.\n`
29
+ );
30
+ try { writeFileSync(marker, String(Date.now())); } catch { /* best-effort marker */ }
31
+ }
package/mem-cli.mjs CHANGED
@@ -28,6 +28,10 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
28
28
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
29
29
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints } from './cli/common.mjs';
30
30
  import { saveObservation } from './lib/save-observation.mjs';
31
+ import {
32
+ insertDeferred, listOpenWithOrdinal, dropDeferred,
33
+ resolveDeferredIds, closeDeferredItems,
34
+ } from './lib/deferred-work.mjs';
31
35
 
32
36
  // ─── Commands ────────────────────────────────────────────────────────────────
33
37
 
@@ -884,7 +888,7 @@ function cmdSave(db, args) {
884
888
  const { positional, flags } = parseArgs(args);
885
889
  const text = positional.join(' ');
886
890
  if (!text) {
887
- fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T]');
891
+ fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T] [--closes-deferred 1,D#42]');
888
892
  return;
889
893
  }
890
894
 
@@ -914,15 +918,60 @@ function cmdSave(db, args) {
914
918
  return;
915
919
  }
916
920
 
917
- const result = saveObservation(db, {
918
- content: text,
919
- title: flags.title,
920
- type,
921
- importance: rawImp,
922
- project,
923
- files: saveFiles,
924
- lesson_learned: rawLesson,
925
- });
921
+ // --closes-deferred parsing: accepts comma-separated mixed tokens
922
+ // ("1,D#42,3") with bare integers treated as ordinals and "D#N" as raw ids.
923
+ // We pre-parse tokens here (cheap, syntax-only) but defer resolveDeferredIds
924
+ // INTO the transaction, AFTER the dedup check. Resolving outside the
925
+ // transaction would throw on the duplicate-replay path: the previously-
926
+ // closed deferred row is no longer 'open', so ordinal/id resolution would
927
+ // crash even though the duplicate short-circuit makes closure a no-op.
928
+ // Resolving inside the dedup-gated branch keeps "save the same content
929
+ // twice" idempotent (mirrors server.mjs:934 dedup-skip-closure intent).
930
+ let closesTokens = null;
931
+ if (flags['closes-deferred'] !== undefined && flags['closes-deferred'] !== false) {
932
+ const raw = String(flags['closes-deferred']);
933
+ closesTokens = raw.split(',').map(t => t.trim()).filter(Boolean).map(t => {
934
+ return /^\d+$/.test(t) ? parseInt(t, 10) : t;
935
+ });
936
+ if (closesTokens.length === 0) {
937
+ fail('[mem] --closes-deferred requires at least one token (integer ordinal or D#N)');
938
+ return;
939
+ }
940
+ }
941
+
942
+ let result;
943
+ let closesIds = null;
944
+ try {
945
+ result = db.transaction(() => {
946
+ const r = saveObservation(db, {
947
+ content: text,
948
+ title: flags.title,
949
+ type,
950
+ importance: rawImp,
951
+ project,
952
+ files: saveFiles,
953
+ lesson_learned: rawLesson,
954
+ });
955
+ // Skip closure on dedup short-circuit — the obs row already exists, so
956
+ // the deferred item should NOT be re-closed by a duplicate save call.
957
+ // Resolving deferred ids only on the non-duplicate path keeps repeated
958
+ // save commands (with the same --closes-deferred) idempotent even after
959
+ // the deferred row has transitioned out of 'open'.
960
+ if (r.kind === 'duplicate') return r;
961
+ if (closesTokens) {
962
+ closesIds = resolveDeferredIds(db, project, closesTokens);
963
+ closeDeferredItems(db, closesIds, r.id);
964
+ }
965
+ return r;
966
+ })();
967
+ } catch (e) {
968
+ if (closesTokens) {
969
+ fail(`[mem] save with --closes-deferred failed: ${e.message}`);
970
+ } else {
971
+ fail(`[mem] save failed: ${e.message}`);
972
+ }
973
+ return;
974
+ }
926
975
 
927
976
  if (result.kind === 'duplicate') {
928
977
  out(`[mem] Skipped: similar to existing #${result.existingId}. Use "claude-mem-lite get ${result.existingId}" to review.`);
@@ -930,7 +979,108 @@ function cmdSave(db, args) {
930
979
  }
931
980
 
932
981
  const lessonNote = result.lessonCaptured ? ' 💡lesson captured' : '';
933
- out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}`);
982
+ const closedNote = closesIds && closesIds.length > 0
983
+ ? ` Closed: ${closesIds.map(i => `D#${i}`).join(', ')}.`
984
+ : '';
985
+ out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}`);
986
+ }
987
+
988
+ // ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
989
+
990
+ function cmdDefer(db, args) {
991
+ const sub = args[0];
992
+ const rest = args.slice(1);
993
+ switch (sub) {
994
+ case 'add': cmdDeferAdd(db, rest); break;
995
+ case 'list': cmdDeferList(db, rest); break;
996
+ case 'drop': cmdDeferDrop(db, rest); break;
997
+ default:
998
+ fail('[mem] Usage: claude-mem-lite defer <add|list|drop> ...');
999
+ fail('[mem] defer add "<title>" [--priority 1|2|3] [--detail T] [--files f1,f2] [--project P]');
1000
+ fail('[mem] defer list [--project P] [--limit N]');
1001
+ fail('[mem] defer drop <id-or-D#N> --reason "<reason>" [--project P]');
1002
+ }
1003
+ }
1004
+
1005
+ function cmdDeferAdd(db, args) {
1006
+ const { positional, flags } = parseArgs(args);
1007
+ const title = positional.join(' ').trim();
1008
+ if (!title) {
1009
+ fail('[mem] Usage: claude-mem-lite defer add "<title>" [--priority 1|2|3] [--detail T] [--files f1,f2] [--project P]');
1010
+ return;
1011
+ }
1012
+ const priority = flags.priority !== undefined ? parseInt(flags.priority, 10) : 2;
1013
+ if (![1, 2, 3].includes(priority)) {
1014
+ fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
1015
+ return;
1016
+ }
1017
+ const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1018
+ const detail = typeof flags.detail === 'string' ? flags.detail : null;
1019
+ const files = flags.files
1020
+ ? flags.files.split(',').map(f => f.trim()).filter(Boolean)
1021
+ : null;
1022
+
1023
+ let r;
1024
+ try {
1025
+ r = insertDeferred(db, { project, title, priority, detail, files });
1026
+ } catch (e) {
1027
+ fail(`[mem] defer add failed: ${e.message}`);
1028
+ return;
1029
+ }
1030
+ // Compute the freshly-inserted row's ordinal for an immediately-actionable
1031
+ // response ("ok, deferred this as item N"). Mirrors server.mjs:980.
1032
+ const open = listOpenWithOrdinal(db, project, 50);
1033
+ const ord = open.find(o => o.id === r.id)?.ordinal ?? '?';
1034
+ out(`[mem] Deferred as D#${r.id} (item ${ord}) in project "${project}".`);
1035
+ }
1036
+
1037
+ function cmdDeferList(db, args) {
1038
+ const { flags } = parseArgs(args);
1039
+ const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1040
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 100 });
1041
+ const list = listOpenWithOrdinal(db, project, limit);
1042
+ if (list.length === 0) {
1043
+ out(`[mem] No open deferred items in project "${project}".`);
1044
+ return;
1045
+ }
1046
+ out(`[mem] Open deferred items (project "${project}"):`);
1047
+ for (const r of list) {
1048
+ const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
1049
+ out(` ${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
1050
+ }
1051
+ }
1052
+
1053
+ function cmdDeferDrop(db, args) {
1054
+ const { positional, flags } = parseArgs(args);
1055
+ if (positional.length === 0) {
1056
+ fail('[mem] Usage: claude-mem-lite defer drop <id-or-D#N> --reason "<reason>" [--project P]');
1057
+ return;
1058
+ }
1059
+ const reason = flags.reason;
1060
+ if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {
1061
+ fail('[mem] defer drop requires --reason "<non-empty string>"');
1062
+ return;
1063
+ }
1064
+ const rawTok = positional[0];
1065
+ // Accept both bare integer (ordinal) and "D#N" string. Mirrors the MCP
1066
+ // mem_defer_drop input contract (server.mjs:1025) by using the same
1067
+ // single-element resolveDeferredIds call.
1068
+ const token = /^\d+$/.test(rawTok) ? parseInt(rawTok, 10) : rawTok;
1069
+ const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1070
+
1071
+ let realId;
1072
+ try {
1073
+ [realId] = resolveDeferredIds(db, project, [token]);
1074
+ } catch (e) {
1075
+ fail(`[mem] defer drop: ${e.message}`);
1076
+ return;
1077
+ }
1078
+ const r = dropDeferred(db, realId, reason);
1079
+ if (r.changed === 0) {
1080
+ out(`[mem] D#${realId} was not in 'open' status — drop is a no-op.`);
1081
+ return;
1082
+ }
1083
+ out(`[mem] Dropped D#${realId} in project "${project}". Reason: ${reason.trim()}`);
934
1084
  }
935
1085
 
936
1086
  // N-1: Quality-focused stats for R-2 A/B baseline.
@@ -2178,6 +2328,19 @@ Commands:
2178
2328
  --project P Project name
2179
2329
  --files f1,f2 Comma-separated file paths
2180
2330
  --lesson T Lesson learned (≤500 chars; alias: --lesson-learned)
2331
+ --closes-deferred 1,D#42 Close deferred items in same transaction
2332
+
2333
+ defer <action> First-class deferred work (v2.70+)
2334
+ add "<title>" Mark deferred work for next session
2335
+ --priority N 1-3 (default 2)
2336
+ --detail T Constraint + why deferred
2337
+ --files f1,f2 Comma-separated file paths
2338
+ --project P Project name
2339
+ list List open deferred items
2340
+ --limit N Max results (default 10)
2341
+ --project P Filter by project
2342
+ drop <D#N|ordinal> Drop a deferred item (no fix needed)
2343
+ --reason "..." Required audit trail
2181
2344
 
2182
2345
  delete <id1,id2,...> Delete observations by ID
2183
2346
  --confirm Execute deletion (preview by default)
@@ -2506,6 +2669,7 @@ export async function run(argv) {
2506
2669
  case 'get': cmdGet(db, cmdArgs); break;
2507
2670
  case 'timeline': cmdTimeline(db, cmdArgs); break;
2508
2671
  case 'save': cmdSave(db, cmdArgs); break;
2672
+ case 'defer': cmdDefer(db, cmdArgs); break;
2509
2673
  case 'delete': cmdDelete(db, cmdArgs); break;
2510
2674
  case 'update': cmdUpdate(db, cmdArgs); break;
2511
2675
  case 'export': cmdExport(db, cmdArgs); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.69.0",
3
+ "version": "2.70.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -62,6 +62,8 @@
62
62
  "lib/metrics.mjs",
63
63
  "lib/mem-override.mjs",
64
64
  "lib/save-observation.mjs",
65
+ "lib/deferred-work.mjs",
66
+ "lib/upgrade-banner.mjs",
65
67
  "cli/common.mjs",
66
68
  "cli/fts-check.mjs",
67
69
  "cli/doctor.mjs",
package/schema.mjs CHANGED
@@ -40,7 +40,12 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
40
40
  // DROP+CREATE so DBs that picked up the strict v29 trigger get the UUID-
41
41
  // gated body. Required because `CREATE TRIGGER IF NOT EXISTS` is a no-op
42
42
  // when the trigger already exists, even with a different body.
43
- export const CURRENT_SCHEMA_VERSION = 30;
43
+ // v31 (v2.70.0): deferred_work table — first-class carry-forward surface.
44
+ // Decoupled from observations: different decay semantics (no time decay; older
45
+ // items rank HIGHER as tech debt accumulates), different lifecycle (mutable
46
+ // status open→done|dropped vs immutable obs). Closure tied to obs via
47
+ // closed_by_obs_id FK with ON DELETE SET NULL (audit trail preserved).
48
+ export const CURRENT_SCHEMA_VERSION = 31;
44
49
 
45
50
  const CORE_SCHEMA = `
46
51
  CREATE TABLE IF NOT EXISTS sdk_sessions (
@@ -541,6 +546,33 @@ export function initSchema(db) {
541
546
  )
542
547
  `);
543
548
 
549
+ // ─── v31 (v2.70.0): deferred_work — carry-forward TODOs ─────────────────────
550
+ // Independent table because decay semantics are inverted (older = higher
551
+ // priority signal) and lifecycle is mutable (status flips). Project-scoped
552
+ // queries; no FTS5 (per-project N expected ≪ 100). Idempotent migration.
553
+ db.exec(`
554
+ CREATE TABLE IF NOT EXISTS deferred_work (
555
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
556
+ project TEXT NOT NULL,
557
+ title TEXT NOT NULL,
558
+ detail TEXT,
559
+ priority INTEGER NOT NULL DEFAULT 2,
560
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','done','dropped')),
561
+ created_at_epoch INTEGER NOT NULL,
562
+ closed_at_epoch INTEGER,
563
+ closed_by_obs_id INTEGER REFERENCES observations(id) ON DELETE SET NULL,
564
+ drop_reason TEXT,
565
+ source_session_id TEXT,
566
+ source_prompt_id INTEGER REFERENCES user_prompts(id) ON DELETE SET NULL,
567
+ files TEXT
568
+ );
569
+ CREATE INDEX IF NOT EXISTS idx_deferred_open
570
+ ON deferred_work(project, priority DESC, created_at_epoch ASC)
571
+ WHERE status = 'open';
572
+ CREATE INDEX IF NOT EXISTS idx_deferred_closed_by
573
+ ON deferred_work(closed_by_obs_id) WHERE closed_by_obs_id IS NOT NULL;
574
+ `);
575
+
544
576
  // Record schema version for fast-path on subsequent calls
545
577
  db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
546
578
  db.transaction(() => {
package/server.mjs CHANGED
@@ -13,7 +13,7 @@ import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, b
13
13
  import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
14
14
  import { effectiveQuiet } from './hook-shared.mjs';
15
15
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
16
- import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
16
+ import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
17
17
 
18
18
  // Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
19
19
  // (discouragement-style, Task 5). This keeps server.mjs from drifting.
@@ -30,6 +30,10 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
30
30
  import { searchResources } from './registry-retriever.mjs';
31
31
  import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
32
32
  import { saveObservation } from './lib/save-observation.mjs';
33
+ import {
34
+ insertDeferred, listOpenWithOrdinal, dropDeferred,
35
+ resolveDeferredIds, closeDeferredItems,
36
+ } from './lib/deferred-work.mjs';
33
37
  import { getVocabulary, rebuildVocabulary, _resetVocabCache, computeVector } from './tfidf.mjs';
34
38
  import { createRequire } from 'module';
35
39
 
@@ -907,22 +911,121 @@ server.registerTool(
907
911
  safeHandler(async (args) => {
908
912
  if (args.project) args = { ...args, project: resolveProject(args.project) };
909
913
  const project = args.project || inferProject();
910
- const result = saveObservation(db, {
911
- content: args.content,
912
- title: args.title,
913
- type: args.type || 'discovery',
914
- importance: args.importance,
915
- project,
916
- files: args.files || [],
917
- lesson_learned: args.lesson_learned,
918
- });
914
+
915
+ let closesIds = null;
916
+ let result;
917
+ try {
918
+ result = db.transaction(() => {
919
+ const r = saveObservation(db, {
920
+ content: args.content,
921
+ title: args.title,
922
+ type: args.type || 'discovery',
923
+ importance: args.importance,
924
+ project,
925
+ files: args.files || [],
926
+ lesson_learned: args.lesson_learned,
927
+ });
928
+ if (r.kind === 'duplicate') return r; // dedup short-circuits BEFORE resolver — replay is idempotent
929
+ // Resolve INSIDE tx + after dedup check so duplicate replays don't throw on
930
+ // already-closed items. Mirrors mem-cli.mjs cmdSave shape.
931
+ if (args.closes_deferred && args.closes_deferred.length > 0) {
932
+ closesIds = resolveDeferredIds(db, project, args.closes_deferred);
933
+ closeDeferredItems(db, closesIds, r.id);
934
+ }
935
+ return r;
936
+ })();
937
+ } catch (e) {
938
+ if (args.closes_deferred && args.closes_deferred.length > 0) {
939
+ // Re-throw with a clearer prefix so MCP error response names the
940
+ // contract failure — gate on caller intent (args.closes_deferred) since
941
+ // closesIds is closure-scoped and may not have been assigned before throw.
942
+ throw new Error(`mem_save with closes_deferred failed: ${e.message}`, { cause: e });
943
+ }
944
+ throw e; // unwrapped — preserves original message + stack
945
+ }
919
946
 
920
947
  if (result.kind === 'duplicate') {
921
948
  return { content: [{ type: 'text', text: `Skipped: similar to existing #${result.existingId} in project "${project}". Use mem_get(ids=[${result.existingId}]) to review.` }] };
922
949
  }
923
950
 
924
951
  const lessonNote = result.lessonCaptured ? ` 💡lesson captured` : '';
925
- return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}` }] };
952
+ const closedNote = closesIds && closesIds.length > 0
953
+ ? ` Closed deferred: ${closesIds.map(i => `D#${i}`).join(', ')}.`
954
+ : '';
955
+ return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}` }] };
956
+ })
957
+ );
958
+
959
+ // ─── Tool: mem_defer ────────────────────────────────────────────────────────
960
+
961
+ server.registerTool(
962
+ 'mem_defer',
963
+ {
964
+ description: descriptionOf('mem_defer'),
965
+ inputSchema: memDeferSchema,
966
+ },
967
+ safeHandler(async (args) => {
968
+ if (args.project) args = { ...args, project: resolveProject(args.project) };
969
+ const project = args.project || inferProject();
970
+ const r = insertDeferred(db, {
971
+ project,
972
+ title: args.title,
973
+ priority: args.priority ?? 2,
974
+ detail: args.detail ?? null,
975
+ files: args.files ?? null,
976
+ });
977
+ // Compute the ordinal for the freshly-inserted row so the response is
978
+ // immediately actionable ("ok, I deferred this as item 1").
979
+ const open = listOpenWithOrdinal(db, project, 50);
980
+ const ord = open.find(o => o.id === r.id)?.ordinal ?? null;
981
+ return { content: [{ type: 'text', text:
982
+ `Deferred as D#${r.id} (item ${ord ?? '?'}) in project "${project}" — surfaces in next SessionStart banner.` }] };
983
+ })
984
+ );
985
+
986
+ // ─── Tool: mem_defer_list ───────────────────────────────────────────────────
987
+
988
+ server.registerTool(
989
+ 'mem_defer_list',
990
+ {
991
+ description: descriptionOf('mem_defer_list'),
992
+ inputSchema: memDeferListSchema,
993
+ },
994
+ safeHandler(async (args) => {
995
+ if (args.project) args = { ...args, project: resolveProject(args.project) };
996
+ const project = args.project || inferProject();
997
+ const list = listOpenWithOrdinal(db, project, args.limit ?? 10);
998
+ if (list.length === 0) {
999
+ return { content: [{ type: 'text', text: `No open deferred items in project "${project}".` }] };
1000
+ }
1001
+ const lines = [`Open deferred items (project "${project}"):`];
1002
+ for (const r of list) {
1003
+ const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
1004
+ lines.push(`${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
1005
+ }
1006
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1007
+ })
1008
+ );
1009
+
1010
+ // ─── Tool: mem_defer_drop ───────────────────────────────────────────────────
1011
+
1012
+ server.registerTool(
1013
+ 'mem_defer_drop',
1014
+ {
1015
+ description: descriptionOf('mem_defer_drop'),
1016
+ inputSchema: memDeferDropSchema,
1017
+ },
1018
+ safeHandler(async (args) => {
1019
+ if (args.project) args = { ...args, project: resolveProject(args.project) };
1020
+ const project = args.project || inferProject();
1021
+ // Resolve id (accept D#N or ordinal int) via resolveDeferredIds with a
1022
+ // single-element array — reuses the same project + status validation.
1023
+ const [realId] = resolveDeferredIds(db, project, [args.id]);
1024
+ const r = dropDeferred(db, realId, args.reason);
1025
+ if (r.changed === 0) {
1026
+ return { content: [{ type: 'text', text: `D#${realId} was not in 'open' status — drop is a no-op.` }] };
1027
+ }
1028
+ return { content: [{ type: 'text', text: `Dropped D#${realId} in project "${project}". Reason: ${args.reason}` }] };
926
1029
  })
927
1030
  );
928
1031
 
@@ -2038,11 +2141,15 @@ server.registerTool(
2038
2141
  );
2039
2142
 
2040
2143
  // ─── Hidden tool filter ─────────────────────────────────────────────────────
2041
- // All 17 tools are registered (so `tools/call <name>` still resolves for
2042
- // scripts and direct MCP clients), but only the 6 core tools appear in the
2043
- // `tools/list` response. Hiding the 11 maintenance/admin tools keeps Claude
2044
- // Code's startup context small while preserving the contract that the plugin
2045
- // dogfoods (see CLAUDE.md §Mem usage contract and adopt-content.mjs).
2144
+ // All tools are registered (so `tools/call <name>` still resolves for scripts
2145
+ // and direct MCP clients), but only the core tools appear in the `tools/list`
2146
+ // response. Hiding the maintenance/admin tools keeps Claude Code's startup
2147
+ // context small while preserving the contract that the plugin dogfoods (see
2148
+ // CLAUDE.md §Mem usage contract and adopt-content.mjs).
2149
+ // Surface counts as of v2.70.0: 9 core (mem_search/recent/timeline/get/save/
2150
+ // recall + mem_defer/mem_defer_list/mem_defer_drop) + 11 hidden (maintenance/
2151
+ // admin/specialized) = 20 registered; tests/tool-schemas.test.mjs is the
2152
+ // authoritative count.
2046
2153
  //
2047
2154
  // Safe because:
2048
2155
  // - Protocol-layer override: we replace the mcp.js default ListTools
@@ -2055,9 +2162,9 @@ const HIDDEN_TOOL_NAMES = new Set(
2055
2162
  );
2056
2163
 
2057
2164
  // Opt-out: setting CLAUDE_MEM_ALL_TOOLS=1 restores pre-v2.34.0 behavior where
2058
- // all 17 tools are visible in `tools/list`. Users who relied on Claude Code
2059
- // autonomously invoking the now-hidden maintenance tools can use this as an
2060
- // immediate escape hatch while adopting the CLI entry points documented in
2165
+ // every registered tool is visible in `tools/list`. Users who relied on Claude
2166
+ // Code autonomously invoking the now-hidden maintenance tools can use this as
2167
+ // an immediate escape hatch while adopting the CLI entry points documented in
2061
2168
  // adopt-content.mjs / README.
2062
2169
  const EXPOSE_ALL_TOOLS = process.env.CLAUDE_MEM_ALL_TOOLS === '1';
2063
2170
 
@@ -2080,7 +2187,7 @@ if (!EXPOSE_ALL_TOOLS) {
2080
2187
  // harnesses stay silent.
2081
2188
  if (!effectiveQuiet()) {
2082
2189
  const status = EXPOSE_ALL_TOOLS
2083
- ? 'all 17 tools exposed via CLAUDE_MEM_ALL_TOOLS=1'
2190
+ ? `all ${TOOL_DEFS.length} tools exposed via CLAUDE_MEM_ALL_TOOLS=1`
2084
2191
  : `tools/list narrowed to ${TOOL_DEFS.length - HIDDEN_TOOL_NAMES.size} core tools (${HIDDEN_TOOL_NAMES.size} hidden but callable by exact name; unset CLAUDE_MEM_ALL_TOOLS to keep, set =1 to restore all)`;
2085
2192
  process.stderr.write(`[claude-mem-lite v${PKG_VERSION}] ${status}\n`);
2086
2193
  }
package/source-files.mjs CHANGED
@@ -63,6 +63,13 @@ export const SOURCE_FILES = [
63
63
  // mem-cli.mjs::cmdSave and server.mjs::mem_save. Statically imported from both
64
64
  // entry points; missing it from the manifest broke MCP saves on auto-update.
65
65
  'lib/save-observation.mjs',
66
+ // v2.70 deferred-work: carry-forward TODO primitives. Statically imported by
67
+ // server.mjs (mem_defer family) and mem-cli.mjs (defer subcommand).
68
+ 'lib/deferred-work.mjs',
69
+ // v2.70 one-shot upgrade banner. Split out of hook.mjs because hook.mjs has
70
+ // module-level `process.exit(0)` side effects that abort vitest workers on
71
+ // direct import. Statically imported by hook.mjs SessionStart handler.
72
+ 'lib/upgrade-banner.mjs',
66
73
  ];
67
74
 
68
75
  /**
package/tool-schemas.mjs CHANGED
@@ -140,6 +140,31 @@ export const memDeleteSchema = {
140
140
  confirm: coerceBool.describe('false=preview what will be deleted, true=execute deletion'),
141
141
  };
142
142
 
143
+ // Coerce closes_deferred input — accepts mixed array of integers (ordinals) and
144
+ // "D#<n>" strings (raw ids). Numeric strings are coerced to numbers (so MCP
145
+ // bridges that JSON-stringify ints don't drop them); D#-prefixed strings stay
146
+ // strings for the resolver. Other string shapes reject early at schema layer.
147
+ const coerceDeferredTokens = z.preprocess(
148
+ (v) => {
149
+ if (Array.isArray(v)) {
150
+ return v.map(x => {
151
+ if (typeof x === 'number') return x;
152
+ if (typeof x === 'string') {
153
+ const s = x.trim();
154
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10);
155
+ return s;
156
+ }
157
+ return x;
158
+ });
159
+ }
160
+ return v;
161
+ },
162
+ z.array(z.union([
163
+ z.number().int().positive(),
164
+ z.string().regex(/^D#\d+$/, 'expected D#N (raw id) or positive integer (ordinal)'),
165
+ ])).min(1).max(20)
166
+ );
167
+
143
168
  export const memSaveSchema = {
144
169
  content: z.string().min(1).max(50000).describe('Memory content to save'),
145
170
  title: z.string().optional().describe('Short title'),
@@ -148,6 +173,7 @@ export const memSaveSchema = {
148
173
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('Importance level: 1=routine, 2=notable, 3=critical (default: 2 for explicit saves)'),
149
174
  files: coerceStringArray.optional().describe('File paths associated with this observation'),
150
175
  lesson_learned: z.string().max(500).optional().describe('Key lesson or takeaway (for bugfix: root cause & fix; for decision: rationale)'),
176
+ closes_deferred: coerceDeferredTokens.optional().describe('Close one or more deferred_work items in the same project. Mixed array: bare integer = ordinal-within-project, "D#<n>" string = raw id. Transactional with the obs insert — a single invalid id rolls back the whole save.'),
151
177
  };
152
178
 
153
179
  export const memStatsSchema = {
@@ -252,6 +278,28 @@ export const memBrowseSchema = {
252
278
  limit: coerceInt.pipe(z.number().int().min(1).max(100)).optional().describe('Max entries per tier (default 5, or 20 when filtering by tier)'),
253
279
  };
254
280
 
281
+ export const memDeferSchema = {
282
+ title: z.string().min(1).max(200).describe('One-line subject of the deferred item'),
283
+ priority: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('1=low, 2=normal, 3=urgent (default: 2)'),
284
+ detail: z.string().max(2000).optional().describe('Optional longer description / constraint / why deferred'),
285
+ files: coerceStringArray.optional().describe('Optional file paths this deferred item concerns'),
286
+ project: z.string().optional().describe('Project name (default: inferred from CWD)'),
287
+ };
288
+
289
+ export const memDeferListSchema = {
290
+ project: z.string().optional().describe('Project name (default: inferred from CWD)'),
291
+ limit: coerceInt.pipe(z.number().int().min(1).max(50)).optional().describe('Max results (default 10)'),
292
+ };
293
+
294
+ export const memDeferDropSchema = {
295
+ id: z.union([
296
+ coerceInt.pipe(z.number().int().positive()),
297
+ z.string().regex(/^D#\d+$/, 'expected D#N or positive integer'),
298
+ ]).describe('Deferred item id — accepts D#N (raw id) or positive integer (ordinal-within-project)'),
299
+ reason: z.string().min(1).max(500).describe('Why this item is being dropped (required for audit trail)'),
300
+ project: z.string().optional().describe('Project name (default: inferred from CWD)'),
301
+ };
302
+
255
303
  // ────────────────────────────────────────────────────────────────────────────
256
304
  // Tool descriptions — discouragement style (Task 5, v2.31)
257
305
  //
@@ -265,14 +313,16 @@ export const memBrowseSchema = {
265
313
  // 40-60% vs. encouragement-style ("use this to..."). See tests/tool-schemas.test.mjs
266
314
  // for the invariants this list must satisfy.
267
315
  //
268
- // Core vs hidden (v2.34.0): only 6 tools are exposed via MCP `tools/list`. The
316
+ // Core vs hidden (v2.34.0, expanded v2.70.0): only 9 tools are exposed via MCP
317
+ // `tools/list` (the original 6 + mem_defer/mem_defer_list/mem_defer_drop). The
269
318
  // remaining 11 stay registered — and are still callable by name at the MCP
270
319
  // protocol level (`tools/call` by exact name) — but are omitted from the list
271
320
  // response so they don't bloat every agent's startup context. The core set
272
321
  // covers the hot paths the invited-memory contract promises (recall before
273
- // Edit, save after bugfix, search/recent/timeline/get for retrieval). Hidden
274
- // tools are either maintenance (compress/maintain/optimize/fts_check),
275
- // admin/infra (stats/export/update/delete), or specialized browsers
322
+ // Edit, save after bugfix, search/recent/timeline/get for retrieval, defer
323
+ // for cross-session carry-forward). Hidden tools are either maintenance
324
+ // (compress/maintain/optimize/fts_check), admin/infra
325
+ // (stats/export/update/delete), or specialized browsers
276
326
  // (browse/registry/use) — all of which have CLI equivalents documented in
277
327
  // `adopt-content.mjs`.
278
328
  // ────────────────────────────────────────────────────────────────────────────
@@ -599,4 +649,57 @@ export const tools = [
599
649
  inputSchema: memBrowseSchema,
600
650
  hidden: true,
601
651
  },
652
+ {
653
+ name: 'mem_defer',
654
+ description:
655
+ 'Save a future-session TODO to the project carry-forward list. Surfaces in the next SessionStart `### Deferred Work` banner with an ordinal (e.g. "1") so user can say "处理1" / "handle item 1".\n' +
656
+ '\n' +
657
+ 'DO NOT use when:\n' +
658
+ ' - Work is in-flight this session (just do it; do not defer mid-task)\n' +
659
+ ' - This-PR follow-up cleanup (file in tasks/, not deferred_work)\n' +
660
+ ' - Already saved a bugfix obs for it (use mem_save closes_deferred instead)\n' +
661
+ '\n' +
662
+ 'USE when:\n' +
663
+ ' - User says "下次/next session/留给下个会话/defer to next round"\n' +
664
+ ' - Wrap-up phase enumerates follow-up items for the next session\n' +
665
+ ' - Bug surfaces but root cause is out of this session\'s scope\n' +
666
+ '\n' +
667
+ 'Equivalent CLI: claude-mem-lite defer add "<title>" [--priority 1|2|3] [--detail "..."] [--files a.mjs,b.mjs]',
668
+ inputSchema: memDeferSchema,
669
+ },
670
+ {
671
+ name: 'mem_defer_list',
672
+ description:
673
+ 'List open deferred_work items for a project, ordered by priority DESC then age. Each item carries a per-project ordinal (1, 2, 3...) for "处理1"-style reference.\n' +
674
+ '\n' +
675
+ 'DO NOT use when:\n' +
676
+ ' - The SessionStart `### Deferred Work` block already shows the items (reuse that — same data)\n' +
677
+ ' - You only need one item by id (use mem_get on the obs that closed it, or look up the row directly)\n' +
678
+ '\n' +
679
+ 'USE when:\n' +
680
+ ' - User asks "what was on the deferred list" / "what did I leave for next time"\n' +
681
+ ' - About to refer to "item N" and need to confirm what N points to\n' +
682
+ ' - Auditing carry-forward state across multiple sessions\n' +
683
+ '\n' +
684
+ 'Equivalent CLI: claude-mem-lite defer list [--project X] [--limit 10]',
685
+ inputSchema: memDeferListSchema,
686
+ },
687
+ {
688
+ name: 'mem_defer_drop',
689
+ description:
690
+ 'Mark a deferred_work item as dropped (no fix, no longer relevant) with a required reason. For real fixes, prefer mem_save({type:"bugfix", closes_deferred:[N]}) — establishes audit trail.\n' +
691
+ '\n' +
692
+ 'DO NOT use when:\n' +
693
+ ' - The item was actually fixed (use mem_save closes_deferred — audit linkage)\n' +
694
+ ' - You want to delete the row (drops are soft — status flips, row stays)\n' +
695
+ ' - The reason is trivial — drop reason is the only audit trail for "why no fix"\n' +
696
+ '\n' +
697
+ 'USE when:\n' +
698
+ ' - Originally-deferred item turns out to be a flaky test, not a real bug\n' +
699
+ ' - Scope changed and the work is no longer needed\n' +
700
+ ' - User explicitly says "drop the deferred X, never mind"\n' +
701
+ '\n' +
702
+ 'Equivalent CLI: claude-mem-lite defer drop <D#N|ordinal> --reason "..."',
703
+ inputSchema: memDeferDropSchema,
704
+ },
602
705
  ];