claude-mem-lite 6.9.1 → 6.10.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.9.1",
12
+ "version": "6.10.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.9.1",
3
+ "version": "6.10.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -119,7 +119,9 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
119
119
  - **Schema auto-migration** -- Idempotent `ALTER TABLE` migrations run on every startup, safely adding new columns and indexes without data loss
120
120
  - **LLM concurrency control** -- File-based semaphore limits background workers to 2 concurrent LLM calls, preventing resource contention
121
121
  - **stdin overflow protection** -- Hook input truncated at 256KB with regex-based action salvage for oversized tool outputs
122
- - **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap. **The `/clear` and `/compact` arm fires since v5.4.0** (R10-P1-1); before that it had never once written a row — `session_handoffs` on the maintainer's install held 4 `exit` rows and **0** `clear` rows. Two host facts settled it, both measured rather than assumed. (1) `Stop` runs at the end of every assistant *turn*, not once per session, and it deleted the session file that SessionStart reads to learn which session just ended — so the branch was unreachable, and mem sessions were minted per turn (58 prompts over 16 host sessions produced 56 mem sessions and 56 summary rows, 2026-09-07). (2) Claude Code **rotates its session id across `/clear`**: of 21 real transcripts, 12 carry a `/clear` command record, and in 12/12 that record's timestamp precedes its own file's first record by ~0.1s — the command is issued in the old session and replayed into a new file under a new id. So `Stop` no longer deletes the file, SessionStart asks the host's `source` (`startup`/`clear`/`compact`/`resume`) instead of guessing from the file, and the handoff's prompt lookup falls back to the unscoped set when the new session's id matches none. Revert path: `CLAUDE_MEM_LEGACY_STOP_UNLINK=1`
122
+ - **Cross-session handoff** -- Captures session state on `/exit` and `/clear`, then injects context when the next session detects continuation intent
123
+ <br>**Changed in v6.10.0**: the injected block went from two sections to six. Its observation queries were keyed on the hook-minted session id while every `mem_save` writes a `manual-<project>` id, so `completed` and `key_decisions` could not reach a saved lesson at all — 17 of 17 stored rows held 0 bytes in both. The block now also carries `## Tree state` (branch, short sha, uncommitted count) and `## Next steps`, read from the project's newest `tasks/<slug>-paused.md` when it is under a week old. Four additive nullable columns land on `session_handoffs`; the schema version deliberately does not move, so an older build still opens the database (measured: the v6.9.1 tree read and wrote a database this release had migrated). Revert by pinning `claude-mem-lite@6.9.1` — no data-directory work.
124
+ <br>Original behaviour and the measurements behind it via explicit keywords or FTS5 term overlap. **The `/clear` and `/compact` arm fires since v5.4.0** (R10-P1-1); before that it had never once written a row — `session_handoffs` on the maintainer's install held 4 `exit` rows and **0** `clear` rows. Two host facts settled it, both measured rather than assumed. (1) `Stop` runs at the end of every assistant *turn*, not once per session, and it deleted the session file that SessionStart reads to learn which session just ended — so the branch was unreachable, and mem sessions were minted per turn (58 prompts over 16 host sessions produced 56 mem sessions and 56 summary rows, 2026-09-07). (2) Claude Code **rotates its session id across `/clear`**: of 21 real transcripts, 12 carry a `/clear` command record, and in 12/12 that record's timestamp precedes its own file's first record by ~0.1s — the command is issued in the old session and replayed into a new file under a new id. So `Stop` no longer deletes the file, SessionStart asks the host's `source` (`startup`/`clear`/`compact`/`resume`) instead of guessing from the file, and the handoff's prompt lookup falls back to the unscoped set when the new session's id matches none. Revert path: `CLAUDE_MEM_LEGACY_STOP_UNLINK=1`
123
125
  - **Git-SHA continuation anchor** (v2.31.0) -- Handoff rows include `git_sha_at_handoff`; any handoff matching the current `HEAD` counts as continuation regardless of TTL. Code state is a stronger continuation signal than wall-clock time
124
126
  - **Startup dashboard** (v2.31.0) -- SessionStart hook aggregates `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + most-recent exit handoff + recent event count into a single structured block injected via `hookSpecificOutput.additionalContext`
125
127
  - **Activity namespace** (v2.31.0) -- Dedicated `events` table + FTS5 for non-memdir types (`bugfix`, `lesson`, `bug`, `discovery`, `refactor`, `feature`, `observation`, `decision`) that don't compete with `WHAT_NOT_TO_SAVE` semantics on the observations table. CLI: `claude-mem-lite activity save|search|recent|show`. `hook-llm` routes non-memdir summary types through `persistHaikuSummary` so upgrades from observations→events are atomic. (v3.39: the `/lesson` and `/bug` slash commands were redirected from this events table to searchable **observations** — `mem_search` never read the events table, so explicit saves were unfindable; the events table remains the auto-capture activity log.)
package/README.zh-CN.md CHANGED
@@ -91,6 +91,7 @@
91
91
  - **LLM 并发控制** -- 基于文件的信号量将后台 worker 限制为 2 个并发 LLM 调用,防止资源争用
92
92
  - **stdin 溢出保护** -- Hook 输入在 256KB 处截断,对超大工具输出使用正则挽救关键信息
93
93
  - **跨会话交接** -- 在 `/clear` 或 `/exit` 时捕获会话状态(请求、已完成工作、后续步骤、关键文件),下次会话检测到继续意图时自动注入上下文(支持显式关键词和 FTS5 术语重叠匹配)
94
+ <br>**v6.10.0 变更**:注入块从 2 段变为 6 段。此前它的观测查询按钩子铸造的会话 id 过滤,而每一次 `mem_save` 写的是 `manual-<project>` id,两个命名空间不相交,所以 `completed` 与 `key_decisions` 根本够不到已保存的经验——库里 17 行交接记录这两个字段全是 0 字节。现在还会带 `## Tree state`(分支、短 sha、未提交文件数)和 `## Next steps`(读取项目里最新且未超过 7 天的 `tasks/<slug>-paused.md`)。`session_handoffs` 新增 4 个可空列;schema 版本号**刻意不动**,所以旧版本仍能打开数据库(已实测:v6.9.1 的代码能读写本版本迁移过的库)。回退方式:固定到 `claude-mem-lite@6.9.1`,无需处理数据目录。
94
95
  - **插件缓存 hook 自愈** -- Claude Code runtime 从 `~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json` 读取插件 hook,而非 marketplace 源。当 `install.mjs` 写入 `settings.json` 的 hooks 与残留 cache `hooks.json` 同时存在(例如曾装过 marketplace 版本,或插件被 Claude Code 自动升级重建 cache),runtime 会注册两套 hook → 每次 SessionStart / UserPromptSubmit 都触发两份。`install.mjs` 和 `hook-update.mjs` 现在会清理每个 cache 版本目录下的 `hooks.json`;`hook.mjs session-start` 每次启动自愈(通过 `hasInstallManagedHooks` 门控,不影响纯插件模式用户);`install.mjs status` 会报告 cache 污染状况(自 v2.31.1 / v2.31.2 起)。
95
96
  - **Git-SHA 延续锚点**(v2.31.0)-- handoff 记录包含 `git_sha_at_handoff` 字段,任何匹配当前 `HEAD` 的 handoff 都视为延续会话,不受 TTL 限制。代码状态比时钟时间更能反映上下文延续。
96
97
  - **启动面板**(v2.31.0)-- SessionStart hook 将 `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + 最近 /exit 交接 + 最近事件数聚合为一个结构化块,通过 `hookSpecificOutput.additionalContext` 注入。
package/hook-context.mjs CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  effectiveQuiet,
28
28
  isQuietHooks,
29
29
  KEY_CONTEXT_LIMIT,
30
+ UNCONSUMED_HANDOFF_SQL,
30
31
  } from './hook-shared.mjs';
31
32
  import { extractUnfinishedSummary } from './hook-handoff.mjs';
32
33
  import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
@@ -752,6 +753,7 @@ export function buildSessionContextLines(
752
753
  SELECT working_on, unfinished, key_files
753
754
  FROM session_handoffs
754
755
  WHERE project = ? AND type = 'clear' AND session_id = ? AND created_at_epoch > ?
756
+ AND ${UNCONSUMED_HANDOFF_SQL}
755
757
  ORDER BY created_at_epoch DESC LIMIT 1
756
758
  `,
757
759
  )
@@ -761,7 +763,7 @@ export function buildSessionContextLines(
761
763
  `
762
764
  SELECT working_on, unfinished, key_files
763
765
  FROM session_handoffs
764
- WHERE project = ? AND type = 'clear' AND created_at_epoch > ?
766
+ WHERE project = ? AND type = 'clear' AND created_at_epoch > ? AND ${UNCONSUMED_HANDOFF_SQL}
765
767
  ORDER BY created_at_epoch DESC LIMIT 1
766
768
  `,
767
769
  )
package/hook-handoff.mjs CHANGED
@@ -14,19 +14,23 @@ import {
14
14
  notLowSignalTitleClause,
15
15
  neutralizeContextDelimiters,
16
16
  } from './utils.mjs';
17
- import { scrubRecord } from './lib/scrub-record.mjs';
17
+ import { scrubRecord, scrubFilePath } from './lib/scrub-record.mjs';
18
18
  import {
19
19
  HANDOFF_EXPIRY_CLEAR,
20
20
  HANDOFF_EXPIRY_EXIT,
21
21
  HANDOFF_ANCHOR_MAX_AGE,
22
22
  HANDOFF_MATCH_THRESHOLD,
23
23
  CONTINUE_KEYWORDS,
24
+ UNCONSUMED_HANDOFF_SQL,
24
25
  } from './hook-shared.mjs';
25
26
  // T10d: import the whole module (not a named export) so tests can spy on
26
27
  // gitStateModule.readGitState via vi.spyOn. Named-import bindings are
27
28
  // immutable in ESM and cannot be mocked after the fact.
28
29
  import * as gitStateModule from './lib/git-state.mjs';
29
30
  import * as taskReaderModule from './lib/task-reader.mjs';
31
+ // Namespace import for the same reason as the two above: ESM named bindings are immutable,
32
+ // so a named import could not be spied on in tests.
33
+ import * as pausedReaderModule from './lib/paused-reader.mjs';
30
34
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
31
35
 
32
36
  /**
@@ -142,6 +146,24 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
142
146
  .get(sessionId, ccScope);
143
147
  if (typeof w?.startEpoch === 'number') ccWindowStart = w.startEpoch;
144
148
  }
149
+ // Fall back to THIS MEM SESSION's own start when the CC window is unavailable, rather
150
+ // than running unbounded.
151
+ //
152
+ // `ccWindowStart` is null by construction on the /clear path: the host rotates the CC id
153
+ // across /clear (measured 12/12, see the note above), so the scope passed here belongs to
154
+ // the NEW session and has no prompts, and MIN over zero rows is null. Before the
155
+ // namespace widening the id predicate still bounded the pool to one session; after it,
156
+ // `OR project = ?` with no window pulled the project's entire recent history — a
157
+ // month-old decision from a different session was reported as this one's Completed AND
158
+ // replayed as standing Key Decisions. Found by the pre-ship defect lens, reproduced
159
+ // end-to-end, and invisible to the suite because every control case passed an
160
+ // `exit`-shaped scope that HAS prompts.
161
+ if (ccWindowStart === null) {
162
+ const w = db
163
+ .prepare(`SELECT MIN(created_at_epoch) AS startEpoch FROM user_prompts WHERE content_session_id = ?`)
164
+ .get(sessionId);
165
+ if (typeof w?.startEpoch === 'number') ccWindowStart = w.startEpoch;
166
+ }
145
167
  const obsWindowClause = ccWindowStart !== null ? 'AND created_at_epoch >= ?' : '';
146
168
  const obsWindowParams = ccWindowStart !== null ? [ccWindowStart] : [];
147
169
 
@@ -155,15 +177,31 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
155
177
  // key_decisions excludes a retracted decision" in tests/audit-silent-20260814.test.mjs.
156
178
  // Audit R8 §11.3 proposed adding the filter here from a repo-wide regex sweep of the
157
179
  // predicate shape; it was rejected on this reasoning, and the guard catches it.
180
+ // `(memory_session_id = ? OR project = ?)`, not the bare id equality this shipped with.
181
+ // The id side alone is unreachable for the rows that matter: the hook mints
182
+ // `hook-<project>-<uuid8>` (hook-shared.mjs) and hands it here, while every explicit
183
+ // mem_save writes `manual-<project>` (lib/save-observation.mjs). Disjoint prefixes, so
184
+ // the join could not match a saved lesson at all. Measured on the live DB 2026-09-21:
185
+ // 101 of 105 observations sat in the `manual-` namespace and all 17 stored handoff rows
186
+ // carried `completed` = 0 bytes.
187
+ //
188
+ // The widening does NOT give back what D#28 bought: isolation is the TIME WINDOW's job
189
+ // (`obsWindowClause`, lower-bounded at this CC session's first prompt), and it still
190
+ // excludes a prior session's rows — pinned by the control case in
191
+ // tests/handoff-payload-reach.test.mjs. The id side is kept as an OR so every row that
192
+ // matched before still matches (project names have been renormalized before, see the
193
+ // `normalize-project-names` migration in schema.mjs, and a row can carry the old name).
194
+ // The earlier citation here and in the commit body pointed at schema.mjs:1127, which is
195
+ // inside the observation_files backfill — the right fact, the wrong line.
158
196
  const completed = db
159
197
  .prepare(
160
198
  `
161
199
  SELECT title, type, narrative FROM observations
162
- WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
200
+ WHERE (memory_session_id = ? OR project = ?) AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
163
201
  ORDER BY created_at_epoch DESC LIMIT 15
164
202
  `,
165
203
  )
166
- .all(sessionId, ...obsWindowParams);
204
+ .all(sessionId, project, ...obsWindowParams);
167
205
 
168
206
  // 3. Recent activity — episode snapshot + full session edit history from narratives.
169
207
  // Keep only entries that represent in-flight work (file edits) or outright failures
@@ -218,24 +256,57 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
218
256
 
219
257
  // 4. Key files — from episode snapshot + observations
220
258
  const fileSet = new Set();
259
+ // Ask the BASENAME for an extension, rather than asking the string for a separator.
260
+ // The separator test answered "does this look like a path", which is a different
261
+ // question and got both halves wrong.
262
+ //
263
+ // key_files is built from TWO sources — the episode buffer on disk (`episodeSnapshot
264
+ // .files`) and `observations.files_modified` — and the measurement covered only the
265
+ // second, so quote it for only that half: over all 218 non-null files_modified entries
266
+ // on the live DB 2026-09-21, 59 (27%) were repo-root filenames like `hook.mjs`, rejected
267
+ // for having no `/`. That is the dropped-file half, and it reproduces.
268
+ //
269
+ // The directory half comes from the EPISODE BUFFER, which that measurement never read:
270
+ // `~/.claude-mem-lite/runtime/ep-<project>.json` carries entries like
271
+ // `/home/ai/dev/loop-testing`, byte-for-byte the key_files of the matching handoff row.
272
+ // `Key Files: claude-mem-lite` in a real injection came from there, NOT from
273
+ // files_modified — no entry in that column equals a project directory. The three
274
+ // extensionless slash-bearing values it does hold are one executable
275
+ // (`claude-plugin/bin/code-graph-mcp`) and two `/var/tmp` scratch dirs, and the
276
+ // executable is an instance of the named cost below rather than evidence for this
277
+ // defect. Corrected by the pre-ship claims lens; the fix is unaffected because
278
+ // isValidFile gates both sources.
279
+ //
280
+ // Named cost, and it is wider than "Makefile, LICENSE" — the pre-ship review diffed old
281
+ // against new over a plausible path set and the drop list is two classes: (a) every
282
+ // extensionless file, which includes the ones under `bin/` and `scripts/` that are
283
+ // executables rather than docs (this repo tracks `.githooks/pre-commit`), and (b) any
284
+ // extension longer than 10 characters, so `.env` qualifies but `.editorconfig` does not.
285
+ // The alternative to the whole rule is a hand-drawn list of extensionless filenames. The
286
+ // alternative is a hand-drawn list of extensionless filenames, and a hand-drawn class is
287
+ // the shape that has been rejected three times in this repo for rejecting real cases.
288
+ // Residual, equally named: a directory that happens to end in `.something` still passes.
289
+ // The dot may lead the basename, so `.env` and `.env.example` qualify.
290
+ const FILE_BASENAME_RE = /\.[A-Za-z0-9_+-]{1,10}$/;
221
291
  const isValidFile = (f) =>
222
292
  f &&
223
293
  f.length > 2 &&
224
- f.includes('/') &&
225
- f.indexOf('/', 1) !== -1 &&
294
+ FILE_BASENAME_RE.test(basename(f)) &&
226
295
  !f.startsWith('/dev/') &&
227
296
  !f.startsWith('/proc/') &&
228
297
  !f.startsWith('/tmp/');
229
298
  if (episodeSnapshot?.files) episodeSnapshot.files.filter(isValidFile).forEach((f) => fileSet.add(f));
299
+ // Same namespace widening as `completed` above — see the reasoning there. Measured
300
+ // 2026-09-21: 8 of the 17 live handoff rows stored key_files as the empty array.
230
301
  const obsFiles = db
231
302
  .prepare(
232
303
  `
233
304
  SELECT files_modified FROM observations
234
- WHERE memory_session_id = ? AND files_modified IS NOT NULL ${obsWindowClause}
305
+ WHERE (memory_session_id = ? OR project = ?) AND files_modified IS NOT NULL ${obsWindowClause}
235
306
  ORDER BY created_at_epoch DESC LIMIT 10
236
307
  `,
237
308
  )
238
- .all(sessionId, ...obsWindowParams);
309
+ .all(sessionId, project, ...obsWindowParams);
239
310
  for (const row of obsFiles) {
240
311
  try {
241
312
  JSON.parse(row.files_modified)
@@ -253,19 +324,68 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
253
324
  // retracted decision rendered there is indistinguishable from live policy. The
254
325
  // carry-forward fallback at the top of this function already filters the same column;
255
326
  // this is the sibling that did not.
327
+ // Same namespace widening as `completed` above — see the reasoning there. This is the
328
+ // field the widening exists for: a `decision` / `bugfix` lesson is written by mem_save,
329
+ // which is exactly the namespace the id equality could not reach, and all 17 live rows
330
+ // carried key_decisions = 0 bytes. The liveness predicate stays the FULL one (this field
331
+ // is replayed to a later session as standing policy — see the note above).
332
+ // `type` alongside the title: the render drops the duplicate copy of each decision from
333
+ // `## Completed`, so this section is the only place those entries still appear and it has
334
+ // to carry the `[bugfix]` / `[decision]` tag that Completed was providing. The only
335
+ // production reader of session_handoffs.key_decisions is this file's own renderer
336
+ // (session_summaries.key_decisions is a different column, a JSON array from Haiku), and
337
+ // every existing assertion on it is a substring/regex match on the title, so the added
338
+ // prefix is not a contract change for them.
256
339
  const decisions = db
257
340
  .prepare(
258
341
  `
259
- SELECT title FROM observations
260
- WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
342
+ SELECT title, type FROM observations
343
+ WHERE (memory_session_id = ? OR project = ?) AND COALESCE(importance, 1) >= 2
261
344
  AND ${liveObsFilterSql('')} ${obsWindowClause}
262
345
  ORDER BY created_at_epoch DESC LIMIT 10
263
346
  `,
264
347
  )
265
- .all(sessionId, ...obsWindowParams)
348
+ .all(sessionId, project, ...obsWindowParams)
266
349
  .filter((d) => d.title && !LOW_SIGNAL_TITLE.test(d.title))
267
350
  .slice(0, 5);
268
351
 
352
+ // 5b. Next steps — the remaining work a paused note spells out, which is the only
353
+ // next-step source in this system that a human wrote down on purpose. Deliberately not
354
+ // folded into `unfinished`: that field renders as "Recent activity" and mixes in-flight
355
+ // edits with surfaced errors, so a hand-written remaining-work list would be mislabelled.
356
+ //
357
+ // Deliberately NOT sourced from deferred_work, even though it is the other project-scoped
358
+ // durable queue: those rows are already delivered at SessionStart by the `### Deferred
359
+ // Work` block in hook-context.mjs, inside `<claude-mem-context>` — NOT by
360
+ // lib/startup-dashboard.mjs, which contains no reference to the table (the claims lens
361
+ // corrected that attribution). It renders the top 5 open rows by priority, so "already
362
+ // delivered" is true of the head of the queue rather than all of it; 12 were open on
363
+ // 2026-09-21. Adding them here would double-inject that head. Nothing delivers the
364
+ // paused note.
365
+ let nextSteps = null;
366
+ try {
367
+ // No explicit projectPath: the reader defaults to cwd, and neutralises that default
368
+ // under the test guard so no suite writes this repo's own paused note into its rows.
369
+ const note = pausedReaderModule.readPausedNote();
370
+ if (note) {
371
+ // Scrub per ELEMENT before stringify, never the JSON string — letting scrubSecrets
372
+ // rewrite the serialized form risks breaking the downstream JSON.parse. Same rule
373
+ // key_files follows below.
374
+ nextSteps = JSON.stringify({
375
+ // scrubFilePath, not scrubSecrets: this field is a filesystem PATH, and eight of
376
+ // the secret patterns carry a value class that does not exclude `/`, so a
377
+ // whole-path scrub eats the separator and destroys the filename. That is the named
378
+ // mechanism this repo grew for exactly this shape; the prose fields below are prose
379
+ // and correctly take the plain scrub.
380
+ file: scrubFilePath(String(note.file)),
381
+ title: scrubSecrets(String(note.title)),
382
+ items: note.items.map((i) => scrubSecrets(String(i))),
383
+ });
384
+ }
385
+ } catch {
386
+ /* best-effort, like the task reader above — never block the handoff */
387
+ }
388
+
269
389
  // 6. Match keywords
270
390
  const allText = [workingOn, ...completed.map((c) => c.title).filter(Boolean), unfinished].join(' ');
271
391
  const keywords = extractMatchKeywords(allText, [...fileSet]);
@@ -273,8 +393,17 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
273
393
  // T10d: capture HEAD sha so detectContinuationIntent can anchor on it later.
274
394
  // Best-effort — failures (non-git dir, missing binary, timeout) yield null.
275
395
  let gitShaAtHandoff = null;
396
+ let gitBranch = null;
397
+ let gitDirtyCount = null;
276
398
  try {
277
- gitShaAtHandoff = gitStateModule.readGitState({ cwd: process.cwd() }).headSha || null;
399
+ const st = gitStateModule.readGitState({ cwd: process.cwd() });
400
+ gitShaAtHandoff = st.headSha || null;
401
+ gitBranch = st.branch || null;
402
+ // 0 and NULL are different answers here: "measured, and the tree is clean" versus "no
403
+ // measurement happened". readGitState returns an empty `changed` for BOTH a clean repo
404
+ // and a directory that is not a repo at all, so the sha/branch decide which one it was.
405
+ // Collapsing them would let a handoff written outside a repo claim a clean tree.
406
+ gitDirtyCount = st.headSha || st.branch ? st.changed.length : null;
278
407
  } catch {
279
408
  /* swallow — handoff must still persist */
280
409
  }
@@ -295,14 +424,23 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
295
424
  working_on: workingOn,
296
425
  completed: completed.map((c) => `[${c.type}] ${c.title}`).join('\n'),
297
426
  unfinished,
298
- key_decisions: decisions.map((d) => d.title).join('\n'),
427
+ key_decisions: decisions.map((d) => `[${d.type}] ${d.title}`).join('\n'),
299
428
  match_keywords: keywords,
300
429
  });
301
430
  const safeKeyFiles = JSON.stringify([...fileSet].slice(0, 20).map((f) => scrubSecrets(String(f))));
431
+ // The UPSERT below resets `consumed_at`. Rewriting a handoff makes it fresh again, so it
432
+ // must become injectable again: the DELETE that consumeHandoff replaced did this
433
+ // implicitly (row gone, next build INSERTed a new one), while the UPSERT reuses the row.
434
+ // Without the reset, a session whose handoff was consumed by a sibling stays permanently
435
+ // invisible to injection however much work it does afterwards. Pre-ship defect lens.
436
+ //
437
+ // This prose lives here and not in the SQL because a backtick inside a SQL comment inside
438
+ // a template literal ends the literal — the same defect this repo shipped at v6.9.1, and
439
+ // it recurred right here while writing this fix.
302
440
  db.prepare(
303
441
  `
304
- INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch, git_sha_at_handoff)
305
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
442
+ INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch, git_sha_at_handoff, git_branch, git_dirty_count, next_steps)
443
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
306
444
  ON CONFLICT(project, type, session_id) DO UPDATE SET
307
445
  working_on = excluded.working_on,
308
446
  completed = excluded.completed,
@@ -311,7 +449,11 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
311
449
  key_decisions = excluded.key_decisions,
312
450
  match_keywords = excluded.match_keywords,
313
451
  created_at_epoch = excluded.created_at_epoch,
314
- git_sha_at_handoff = excluded.git_sha_at_handoff
452
+ git_sha_at_handoff = excluded.git_sha_at_handoff,
453
+ git_branch = excluded.git_branch,
454
+ git_dirty_count = excluded.git_dirty_count,
455
+ next_steps = excluded.next_steps,
456
+ consumed_at = NULL
315
457
  `,
316
458
  ).run(
317
459
  project,
@@ -327,6 +469,9 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
327
469
  safe.match_keywords,
328
470
  Date.now(),
329
471
  gitShaAtHandoff,
472
+ gitBranch,
473
+ gitDirtyCount,
474
+ nextSteps,
330
475
  );
331
476
  }
332
477
 
@@ -371,6 +516,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
371
516
  `
372
517
  SELECT created_at_epoch, match_keywords FROM session_handoffs
373
518
  WHERE project = ? AND git_sha_at_handoff = ? AND (type = 'exit' OR session_id = ?)
519
+ AND ${UNCONSUMED_HANDOFF_SQL}
374
520
  ORDER BY created_at_epoch DESC LIMIT 1
375
521
  `,
376
522
  )
@@ -379,7 +525,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
379
525
  .prepare(
380
526
  `
381
527
  SELECT created_at_epoch, match_keywords FROM session_handoffs
382
- WHERE project = ? AND git_sha_at_handoff = ?
528
+ WHERE project = ? AND git_sha_at_handoff = ? AND ${UNCONSUMED_HANDOFF_SQL}
383
529
  ORDER BY created_at_epoch DESC LIMIT 1
384
530
  `,
385
531
  )
@@ -405,7 +551,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
405
551
  .prepare(
406
552
  `
407
553
  SELECT created_at_epoch, match_keywords FROM session_handoffs
408
- WHERE project = ? AND type = 'clear' AND session_id = ?
554
+ WHERE project = ? AND type = 'clear' AND session_id = ? AND ${UNCONSUMED_HANDOFF_SQL}
409
555
  ORDER BY created_at_epoch DESC LIMIT 1
410
556
  `,
411
557
  )
@@ -414,7 +560,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
414
560
  .prepare(
415
561
  `
416
562
  SELECT created_at_epoch, match_keywords FROM session_handoffs
417
- WHERE project = ? AND type = 'clear'
563
+ WHERE project = ? AND type = 'clear' AND ${UNCONSUMED_HANDOFF_SQL}
418
564
  ORDER BY created_at_epoch DESC LIMIT 1
419
565
  `,
420
566
  )
@@ -455,6 +601,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
455
601
  SELECT type, match_keywords, created_at_epoch FROM session_handoffs
456
602
  WHERE project = ?
457
603
  AND ((type = 'clear' AND session_id = ?) OR type = 'exit')
604
+ AND ${UNCONSUMED_HANDOFF_SQL}
458
605
  ORDER BY created_at_epoch DESC
459
606
  `,
460
607
  )
@@ -463,7 +610,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
463
610
  .prepare(
464
611
  `
465
612
  SELECT type, match_keywords, created_at_epoch FROM session_handoffs
466
- WHERE project = ? ORDER BY created_at_epoch DESC
613
+ WHERE project = ? AND ${UNCONSUMED_HANDOFF_SQL} ORDER BY created_at_epoch DESC
467
614
  `,
468
615
  )
469
616
  .all(project);
@@ -518,6 +665,7 @@ export function pickHandoffToInject(db, project, currentCcSessionId = null) {
518
665
  SELECT * FROM session_handoffs
519
666
  WHERE project = ?
520
667
  AND ((type = 'clear' AND session_id = ?) OR (type = 'exit' AND session_id != ?))
668
+ AND ${UNCONSUMED_HANDOFF_SQL}
521
669
  ORDER BY created_at_epoch DESC LIMIT 5
522
670
  `,
523
671
  )
@@ -526,7 +674,7 @@ export function pickHandoffToInject(db, project, currentCcSessionId = null) {
526
674
  .prepare(
527
675
  `
528
676
  SELECT * FROM session_handoffs
529
- WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
677
+ WHERE project = ? AND ${UNCONSUMED_HANDOFF_SQL} ORDER BY created_at_epoch DESC LIMIT 5
530
678
  `,
531
679
  )
532
680
  .all(project);
@@ -539,12 +687,97 @@ export function pickHandoffToInject(db, project, currentCcSessionId = null) {
539
687
  );
540
688
  }
541
689
 
690
+ /**
691
+ * Mark one handoff row as delivered.
692
+ *
693
+ * Replaces the DELETE that used to follow injection. Two things that DELETE cost: a handoff
694
+ * injected at a moment the model could not act on it was gone for good, and the row behind a
695
+ * bad injection no longer existed by the time anyone went looking for it. Marking keeps the
696
+ * row until the existing age-based GC in hook.mjs's auto-maintain reaps it, so retention is
697
+ * unchanged in the limit — only the window in which it can be read back grows.
698
+ *
699
+ * Scoped to the exact PK so a parallel session's handoff is untouched (the DELETE this
700
+ * replaces already had that property, and pre-v2.46 not having it made the DB forgetful).
701
+ *
702
+ * @param {Database} db Opened main database
703
+ * @param {{project: string, type: string, session_id: string}} handoff Row from pickHandoffToInject
704
+ * @param {number} [now=Date.now()] Injected for tests
705
+ * @returns {number} Rows changed (0 when a concurrent session consumed it first)
706
+ */
707
+ export function consumeHandoff(db, handoff, now = Date.now()) {
708
+ if (!handoff) return 0;
709
+ // `consumed_at IS NULL` in the WHERE, not just the SET: two sessions can race to inject
710
+ // the same exit handoff, and the first stamp is the one that should stand.
711
+ const res = db
712
+ .prepare(
713
+ `UPDATE session_handoffs SET consumed_at = ?
714
+ WHERE project = ? AND type = ? AND session_id = ? AND ${UNCONSUMED_HANDOFF_SQL}`,
715
+ )
716
+ .run(now, handoff.project, handoff.type, handoff.session_id);
717
+ return res.changes;
718
+ }
719
+
542
720
  export function renderHandoffInjection(db, project, currentCcSessionId = null) {
543
721
  const handoff = pickHandoffToInject(db, project, currentCcSessionId);
544
722
  if (!handoff) return null;
545
723
  return renderHandoffFromRow(handoff, db, project);
546
724
  }
547
725
 
726
+ // Markdown ATX markers carried by replayed text, at a token boundary.
727
+ //
728
+ // This block frames itself with `## Working On` / `## Completed` / `## Next steps`, and
729
+ // working_on is user prompt text — a prompt that opens with its own outline flattens into
730
+ // the block carrying `#` and `##` of its own, on the same line, because working_on joins up
731
+ // to five prompts with ` → `. A real injection read
732
+ // `## Working On` / `# 自主端到端测试与修复循环 ## 角色与授权 …`, at which point the
733
+ // block's structure and the replayed text's structure are indistinguishable to whatever
734
+ // reads it next. Same class as the authority-tag defanging one level down: a forged
735
+ // SECTION rather than a forged tag.
736
+ //
737
+ // Only a marker followed by whitespace, at a token boundary, counts — `#42`, `C#` and
738
+ // `D#216` are ordinary in this project's prose and survive untouched.
739
+ const ATX_HEADING_RE = /(^|\s)#{1,6}\s/g;
740
+ const ATX_MAX_PASSES = 32;
741
+
742
+ /**
743
+ * Strip ATX markers to a FIXPOINT, not in one pass.
744
+ *
745
+ * The gap is two characters wide and it is the same one format-utils' `defangToFixpoint`
746
+ * documents for the tag half: `(^|\s)` CONSUMES the boundary, so after removing the first
747
+ * `## ` the regex resumes past the second one and leaves it live. `## ## Key Decisions`
748
+ * came out of a single pass as a real `## Key Decisions` section inside the block — the
749
+ * precise property this defanging exists to hold, defeated by two extra characters. Found
750
+ * by the pre-ship defect lens; reproduced end-to-end before the fix.
751
+ *
752
+ * TERMINATION: every match contains at least one `#` and the replacement drops all of them,
753
+ * so any pass that changes the string removes at least one `#`. Self-bounded by the number
754
+ * of `#` in the input, and bounded again by the constant.
755
+ *
756
+ * INERT AT ANY DEPTH: still changing at the cap (≥32 nested forged layers, not reachable by
757
+ * accident) → drop every remaining `#`. Lossier, but the return value then provably carries
758
+ * no marker, which is the property callers rely on. Same fail-closed shape as the sibling.
759
+ */
760
+ function stripAtxToFixpoint(s) {
761
+ let text = s;
762
+ for (let pass = 0; pass < ATX_MAX_PASSES; pass++) {
763
+ const next = text.replace(ATX_HEADING_RE, '$1');
764
+ if (next === text) return text;
765
+ text = next;
766
+ }
767
+ return text.replace(/#/g, '');
768
+ }
769
+
770
+ /** Defang authority tags AND section markers, for any replayed free text. */
771
+ function safeText(value) {
772
+ return stripAtxToFixpoint(neutralizeContextDelimiters(String(value)));
773
+ }
774
+
775
+ // `[bugfix] title` → `title`. Both `completed` and `key_decisions` store the observation
776
+ // type this way; the bracket run is length-capped so a title that merely opens with a
777
+ // bracket ("[WIP] ..." is not a type) is not silently truncated to nothing.
778
+ const TYPE_PREFIX_RE = /^\[[^\]]{1,20}\]\s*/;
779
+ const titleOf = (line) => line.replace(TYPE_PREFIX_RE, '').trim();
780
+
548
781
  function renderHandoffFromRow(handoff, db, project) {
549
782
  const ageSec = Math.round((Date.now() - handoff.created_at_epoch) / 1000);
550
783
  const ageStr =
@@ -572,16 +805,66 @@ function renderHandoffFromRow(handoff, db, project) {
572
805
  // user prompt or edit snippet carrying a literal </session-handoff> would otherwise
573
806
  // close the block early and the rest would read as a real user message.
574
807
  if (handoff.working_on) {
575
- lines.push('## Working On', neutralizeContextDelimiters(handoff.working_on), '');
808
+ lines.push('## Working On', safeText(handoff.working_on), '');
576
809
  }
577
- if (handoff.completed) {
578
- lines.push(
579
- '## Completed',
580
- ...neutralizeContextDelimiters(handoff.completed)
810
+
811
+ // Tree state. The sha has been stored since v25 but was only ever an INPUT to the
812
+ // continuation anchor — it was never shown, so a resumed session opened by running
813
+ // `git status` / `git rev-parse` to find out where it stood. Rendered right after the
814
+ // objective, because "which branch, and is the tree dirty" is the next question after
815
+ // "what was I doing". Branch names are defanged for the same reason key_files basenames
816
+ // are: git ref names admit angle brackets, and this text is replayed into the prompt.
817
+ // A 7-char sha cannot carry a complete tag, so it is sliced rather than scrubbed.
818
+ const treeBits = [];
819
+ if (handoff.git_branch) treeBits.push(`branch ${safeText(handoff.git_branch)}`);
820
+ if (handoff.git_sha_at_handoff) treeBits.push(`@ ${String(handoff.git_sha_at_handoff).slice(0, 7)}`);
821
+ if (typeof handoff.git_dirty_count === 'number') {
822
+ // NULL stays silent: a row written before this shipped, or written outside a repo, has
823
+ // no measurement, and "clean" would be a claim nobody made.
824
+ treeBits.push(handoff.git_dirty_count === 0 ? 'clean' : `${handoff.git_dirty_count} uncommitted file(s)`);
825
+ }
826
+ if (treeBits.length > 0) lines.push('## Tree state', treeBits.join(' · '), '');
827
+
828
+ // Key Decisions is computed here, before Completed renders, because Completed is deduped
829
+ // against it. Measured on the live corpus 2026-09-21 with the real predicates, population
830
+ // = every project with observations: 35 of 35 rendered Key Decisions lines were
831
+ // byte-identical to a Completed line, in all 8 projects. The overlap only became visible
832
+ // once the payload fix landed — before that both sections were empty.
833
+ //
834
+ // One-directional on purpose, and this is the F4 ruling rather than a preference:
835
+ // `completed` is the session's OWN HISTORY, `key_decisions` is standing policy replayed to
836
+ // a LATER session, which is why only the latter filters superseded_at. So the line to drop
837
+ // is the duplicate in history, and the one case where the two genuinely differ — a
838
+ // retracted decision, absent from key_decisions — is exactly the case where nothing is
839
+ // dropped and the history keeps it. Render-time only: the stored row is untouched, so all
840
+ // three F4 guards in tests/audit-silent-20260814.test.mjs still read what they pinned.
841
+ const decisionLines = handoff.key_decisions
842
+ ? safeText(handoff.key_decisions)
581
843
  .split('\n')
582
- .map((l) => `- ${l}`),
583
- '',
584
- );
844
+ .filter((l) => l.trim())
845
+ : [];
846
+ // Match on the WHOLE line. Matching on the stripped title collapsed two DIFFERENT
847
+ // observations that share a title but not a type — `[change] 更新测试` vanished from
848
+ // Completed because `[decision] 更新测试` was standing policy — and that false match was
849
+ // permanent while the thing it accommodated is not. Pre-ship defect lens.
850
+ const decisionWhole = new Set(decisionLines);
851
+ // The accommodation, scoped to the rows that actually need it: a row written before
852
+ // key_decisions carried the `[type]` prefix holds a bare title, so only those get the
853
+ // looser title-only match. They age out at the handoff's own expiry.
854
+ const legacyTitles = new Set(decisionLines.filter((l) => !TYPE_PREFIX_RE.test(l)).map((l) => titleOf(l)));
855
+ const isDuplicateOfDecision = (line) => decisionWhole.has(line) || legacyTitles.has(titleOf(line));
856
+
857
+ if (handoff.completed) {
858
+ const kept = safeText(handoff.completed)
859
+ .split('\n')
860
+ .filter((l) => l.trim() && !isDuplicateOfDecision(l));
861
+ // An empty `## Completed` header is worse than no header — TWO of the eight measured
862
+ // projects had a session whose entire history was its decisions (dev--daagu and
863
+ // scratchpad--loop-smoke; the next closest keeps 2 lines). The commit body and an
864
+ // earlier draft of this comment said three; re-derived at both the 105- and
865
+ // 107-observation corpus states it is two. No type tags are lost with the header:
866
+ // key_decisions carries them now too.
867
+ if (kept.length > 0) lines.push('## Completed', ...kept.map((l) => `- ${l}`), '');
585
868
  }
586
869
  if (handoff.unfinished) {
587
870
  // Extract only the pending-work portion (before narrative history separator).
@@ -592,7 +875,7 @@ function renderHandoffFromRow(handoff, db, project) {
592
875
  if (pending) {
593
876
  lines.push(
594
877
  '## Recent activity',
595
- ...neutralizeContextDelimiters(pending)
878
+ ...safeText(pending)
596
879
  .split('; ')
597
880
  .map((l) => `- ${l}`),
598
881
  '',
@@ -606,17 +889,32 @@ function renderHandoffFromRow(handoff, db, project) {
606
889
  // (Linux allows almost any char but '/'), and this is the one field in this block
607
890
  // that was rendered raw while working_on/unfinished/key_decisions all neutralize.
608
891
  if (files.length > 0)
609
- lines.push('## Key Files', neutralizeContextDelimiters(files.map((f) => basename(f)).join(', ')), '');
892
+ lines.push('## Key Files', safeText(files.map((f) => basename(f)).join(', ')), '');
610
893
  } catch {}
611
894
  }
612
- if (handoff.key_decisions) {
613
- lines.push(
614
- '## Key Decisions',
615
- ...neutralizeContextDelimiters(handoff.key_decisions)
616
- .split('\n')
617
- .map((l) => `- ${l}`),
618
- '',
619
- );
895
+ // Next steps, from the project's newest paused note. Placed after Key Files and before
896
+ // Key Decisions: it is the most actionable block here, and it cites its own source file
897
+ // so the resuming session can open the full note instead of trusting this summary.
898
+ // Defanged like every other free-text field — the note is repo text, replayed verbatim
899
+ // into the prompt, and a literal closer would end the block early.
900
+ if (handoff.next_steps) {
901
+ try {
902
+ const note = JSON.parse(handoff.next_steps);
903
+ if (Array.isArray(note?.items) && note.items.length > 0) {
904
+ lines.push('## Next steps');
905
+ const from = note.file ? ` (from ${safeText(String(note.file))})` : '';
906
+ if (note.title) lines.push(`${safeText(String(note.title))}${from}`);
907
+ else if (from) lines.push(from.trim());
908
+ for (const item of note.items) lines.push(`- ${safeText(String(item))}`);
909
+ lines.push('');
910
+ }
911
+ } catch {
912
+ /* malformed JSON — skip, same as key_files */
913
+ }
914
+ }
915
+
916
+ if (decisionLines.length > 0) {
917
+ lines.push('## Key Decisions', ...decisionLines.map((l) => `- ${l}`), '');
620
918
  }
621
919
 
622
920
  lines.push('</session-handoff>');
@@ -660,10 +958,9 @@ function renderHandoffFromRow(handoff, db, project) {
660
958
  // Defang: these come from session_summaries, populated by Haiku OR by
661
959
  // extractStructuredSummary over the assistant transcript tail — replayed text that can
662
960
  // carry tool-XML / forged authority tags, same class as working_on above (audit MED-4).
663
- if (summary.completed) lines.push(neutralizeContextDelimiters(summary.completed));
664
- if (summary.remaining_items)
665
- lines.push(`Remaining: ${neutralizeContextDelimiters(summary.remaining_items)}`);
666
- if (summary.next_steps) lines.push(`Next steps: ${neutralizeContextDelimiters(summary.next_steps)}`);
961
+ if (summary.completed) lines.push(safeText(summary.completed));
962
+ if (summary.remaining_items) lines.push(`Remaining: ${safeText(summary.remaining_items)}`);
963
+ if (summary.next_steps) lines.push(`Next steps: ${safeText(summary.next_steps)}`);
667
964
  lines.push('</session-summary>');
668
965
  }
669
966
  } catch {}
package/hook-shared.mjs CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  HANDOFF_ANCHOR_MAX_AGE,
44
44
  HANDOFF_MATCH_THRESHOLD,
45
45
  CONTINUE_KEYWORDS,
46
+ UNCONSUMED_HANDOFF_SQL,
46
47
  } from './lib/handoff-constants.mjs';
47
48
 
48
49
  import { DAY_MS, ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
package/hook.mjs CHANGED
@@ -138,6 +138,7 @@ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
138
138
  import { selectErrorRecall } from './lib/error-recall-core.mjs';
139
139
  import {
140
140
  buildAndSaveHandoff,
141
+ consumeHandoff,
141
142
  detectContinuationIntent,
142
143
  renderHandoffInjection,
143
144
  pickHandoffToInject,
@@ -1974,9 +1975,11 @@ function runSessionStartAutoMaintain(db, project) {
1974
1975
  debugCatch(e, 'auto-maintain-orphan-sweep');
1975
1976
  }
1976
1977
 
1977
- // GC expired session_handoffs: the consume-DELETE (handleSessionStart) only removes
1978
- // the single handoff a continuation reads back; an 'exit'/'compact' that is never
1979
- // resumed (and every superseded 'clear') lingers forever — read paths filter by
1978
+ // GC expired session_handoffs. This is now the ONLY reaper: consuming a handoff
1979
+ // stamps `consumed_at` (injectHandoffIfEarly, the UserPromptSubmit path) instead of
1980
+ // deleting the row, so nothing removes rows but this. Before that change a consume
1981
+ // did delete one row, and an 'exit'/'compact' that is never
1982
+ // resumed (and every superseded 'clear') lingered forever — read paths filter by
1980
1983
  // expiry but nothing reaped the rows. Delete past-expiry rows with a +1d margin so a
1981
1984
  // still-readable handoff is never raced away. 'clear' 6h+1d, 'exit'/other 7d+1d.
1982
1985
  try {
@@ -2893,12 +2896,13 @@ function injectHandoffIfEarly(db, { project, promptText, promptNumber, ccSession
2893
2896
  // Pre-v2.46 wiped every exit handoff for the project on any continuation
2894
2897
  // intent, which made the DB effectively forgetful: 115 completed sessions
2895
2898
  // produced 1 persisted handoff.
2899
+ //
2900
+ // Consuming STAMPS the row (consumed_at) rather than deleting it: the age-based
2901
+ // GC in auto-maintain still reaps it, but until then the row that produced this
2902
+ // injection can be read back. Every pool that relied on the row being gone now
2903
+ // filters on UNCONSUMED_HANDOFF_SQL instead — the list is in handoff-constants.
2896
2904
  try {
2897
- db.prepare('DELETE FROM session_handoffs WHERE project = ? AND type = ? AND session_id = ?').run(
2898
- project,
2899
- picked.type,
2900
- picked.session_id,
2901
- );
2905
+ consumeHandoff(db, picked);
2902
2906
  } catch {}
2903
2907
  }
2904
2908
  }
package/lib/git-state.mjs CHANGED
@@ -47,7 +47,10 @@ export function readGitState({ cwd = process.cwd() } = {}) {
47
47
  const changed = statusOut ? statusOut.split('\n').filter(Boolean) : [];
48
48
  const stashOut = run('git', ['stash', 'list'], { cwd });
49
49
  const stashes = stashOut ? stashOut.split('\n').filter(Boolean) : [];
50
- const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }) || null;
50
+ // `symbolic-ref`, not `rev-parse --abbrev-ref`: the latter returns the literal string
51
+ // "HEAD" on a detached head, which the handoff's tree-state line then rendered as
52
+ // `branch HEAD`. Failing to null is the honest answer — there is no branch.
53
+ const branch = run('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd }) || null;
51
54
  const headSha = run('git', ['rev-parse', 'HEAD'], { cwd }) || null;
52
55
  return { changed, stashes, branch, headSha };
53
56
  }
@@ -14,5 +14,18 @@ export const HANDOFF_EXPIRY_CLEAR = 6 * 3600000; // 6 hours (covers lunch/meetin
14
14
  export const HANDOFF_EXPIRY_EXIT = 7 * 24 * 60 * 60 * 1000; // 7 days
15
15
  export const HANDOFF_ANCHOR_MAX_AGE = 72 * 3600000; // 72h cap on git_sha anchor — avoids stale-HEAD false positives
16
16
  export const HANDOFF_MATCH_THRESHOLD = 3; // min weighted score
17
+
18
+ // Availability predicate for a stored handoff. Injecting one STAMPS `consumed_at`
19
+ // (hook-handoff.mjs::consumeHandoff) instead of deleting the row, so every pool that used
20
+ // to rely on the row simply being GONE has to say so now. Spelled once, here, because the
21
+ // five sites that need it are a decision and not a sweep:
22
+ // - pickHandoffToInject (both arms) — else the same row re-injects on prompts 2-3
23
+ // - detectContinuationIntent Stage -1/0/2 — a consumed handoff is not resumable
24
+ // - hook-context's "Working State (from /clear)" block
25
+ // - startup-dashboard's "Continuation available" pointer, whose stated contract is to
26
+ // only promise what the injection could actually deliver
27
+ // The one deliberate NON-site is hook.mjs's read-back immediately after buildAndSaveHandoff:
28
+ // it reads the row it just wrote, by exact PK, before anything could have consumed it.
29
+ export const UNCONSUMED_HANDOFF_SQL = 'consumed_at IS NULL';
17
30
  export const CONTINUE_KEYWORDS =
18
31
  /继续|接着|上次|之前的|前面的|刚才|\bcontinue\b|\bresume\b|\bwhere[\s-]+we[\s-]+left\b|\bpick[\s-]+up\b|\bcarry[\s-]+on\b/i;
@@ -0,0 +1,167 @@
1
+ // lib/paused-reader.mjs — read the newest `tasks/<slug>-paused.md` in a project.
2
+ //
3
+ // A paused note is the one place a session writes down BY HAND what is left and how to
4
+ // verify it; the spec governing this repo makes writing one mandatory when a session exits
5
+ // mid-task. Nothing read them. Measured 2026-09-21: 27 such files across 7 projects on this
6
+ // machine, zero readers — the handoff was reconstructing "what next" from tool history
7
+ // while the answer sat in the repo in prose.
8
+ //
9
+ // It is also why the next step is taken from a FILE rather than from a model summary:
10
+ // session_summaries.next_steps is non-empty in 11 of 310 rows (3.5%), so that route has
11
+ // already been measured and does not work.
12
+ //
13
+ // A leaf on purpose — `fs`/`path` only, no package imports and no edge back into the hook
14
+ // layer, so importing it costs nothing at load time (same reason lib/data-paths.mjs is a
15
+ // leaf). Every failure mode (missing dir, unreadable file, binary content, races between
16
+ // readdir and stat) yields null or fewer items, never a throw: this runs inside handoff
17
+ // construction, which must still persist a row.
18
+ import { readdirSync, readFileSync, statSync } from 'fs';
19
+ import { join, basename } from 'path';
20
+ // The one non-builtin import, and it does not cost the leaf property: format-utils' only
21
+ // edge is lib/time-constants.mjs, itself importless. Reused rather than reimplemented
22
+ // because a raw slice gets two things wrong that this function already documents — it
23
+ // leaves no mark that the text was cut, and it can split a UTF-16 surrogate pair and emit
24
+ // a lone surrogate.
25
+ import { truncate } from '../format-utils.mjs';
26
+ // Also importless — the handoff policy module. One number for "how old is too old" rather
27
+ // than a second constant that can drift away from it.
28
+ import { HANDOFF_EXPIRY_EXIT } from './handoff-constants.mjs';
29
+
30
+ // Headings whose body is REMAINING WORK, in priority order — the first one present wins,
31
+ // regardless of where it sits in the document. "Resume" is last because the generated notes
32
+ // use it for a generic instruction, which is worth something but less than an explicit list.
33
+ // Deliberately NOT matched: "Last mutation tool call(s)" and the completed sections, whose
34
+ // bullets read like work items and are the opposite of work items.
35
+ const REMAINING_HEADINGS = [
36
+ /^#{1,6}\s*(?:未完成|待办|剩余(?:工作)?)\s*$/i,
37
+ /^#{1,6}\s*(?:not\s+done|remaining(?:\s+work)?|todo|next\s+steps?)\s*$/i,
38
+ /^#{1,6}\s*(?:resume|恢复)\s*$/i,
39
+ ];
40
+
41
+ // "# Paused — X" / "# 暂停 — X" / "# Paused: X" → X. A note whose title is only the word
42
+ // keeps the whole line rather than becoming empty.
43
+ const TITLE_PREFIX_RE = /^#{1,6}\s*(?:paused|暂停)\s*(?:[—–\-::]\s*)?/i;
44
+ const LIST_MARKER_RE = /^(?:[-*+]|\d+[.)])\s+/;
45
+ const MAX_ITEM_CHARS = 200;
46
+ // The read is synchronous and sits on the Stop path, once per assistant turn. Output was
47
+ // already bounded (5 items x 200 chars) and the INPUT was not — a note is prose a human
48
+ // wrote, so anything past this is not a note. Pre-ship defect lens.
49
+ const MAX_NOTE_BYTES = 256 * 1024;
50
+
51
+ /**
52
+ * Default project root, with test containment on the same channel lib/resolve-data-dir.mjs
53
+ * uses (audit 2026-08-22 P2-4).
54
+ *
55
+ * Under vitest the default resolves to the maintainer's real repo, which carries fifteen
56
+ * `tasks/*-paused.md` files of its own — so every suite that builds a handoff would quietly
57
+ * write this repo's paused note into the row under test. That was measured, not imagined:
58
+ * a probe over an unstubbed buildAndSaveHandoff came back carrying
59
+ * `tasks/session-end-b3c0c20d-paused.md`.
60
+ *
61
+ * Per-file `vi.spyOn` stubs fix the same leak but are discipline, not structure — nothing
62
+ * goes red when a new suite forgets one, which is how the sibling readers (readGitState,
63
+ * readProjectTasks) still leak into the files that do not stub them. Neutralising the
64
+ * DEFAULT instead makes the leak impossible while leaving an explicit `projectPath`
65
+ * untouched, so this module's own tests still read their temp dirs.
66
+ *
67
+ * The var is inherited by spawned hooks too, so e2e subprocesses are contained as well.
68
+ */
69
+ function defaultProjectPath() {
70
+ return process.env.CLAUDE_MEM_TEST_GUARD === '1' ? null : process.cwd();
71
+ }
72
+
73
+ /**
74
+ * Read the newest paused note for a project.
75
+ *
76
+ * @param {object} [options]
77
+ * @param {string} [options.projectPath] Absolute path to the project root. Defaults to the
78
+ * current working directory, and to nothing at all under the test guard.
79
+ * @param {number} [options.maxItems=5] Cap on returned items.
80
+ * @param {number} [options.maxAgeMs=HANDOFF_EXPIRY_EXIT] Ignore notes older than this.
81
+ * @returns {{file: string, title: string, items: string[]}|null} null when there is no note,
82
+ * the newest one is stale, or it has nothing left to do.
83
+ */
84
+ export function readPausedNote({
85
+ projectPath = defaultProjectPath(),
86
+ maxItems = 5,
87
+ maxAgeMs = HANDOFF_EXPIRY_EXIT,
88
+ } = {}) {
89
+ if (!projectPath) return null;
90
+ const tasksDir = join(projectPath, 'tasks');
91
+ let newest = null;
92
+ try {
93
+ for (const name of readdirSync(tasksDir)) {
94
+ if (!name.endsWith('-paused.md')) continue;
95
+ try {
96
+ const st = statSync(join(tasksDir, name));
97
+ if (st.size > MAX_NOTE_BYTES) continue;
98
+ if (!newest || st.mtimeMs > newest.mtime) newest = { name, mtime: st.mtimeMs };
99
+ } catch {
100
+ /* vanished between readdir and stat — skip it */
101
+ }
102
+ }
103
+ } catch {
104
+ return null; // no tasks/ dir
105
+ }
106
+ if (!newest) return null;
107
+ // Staleness bound, on the NEWEST note only — if the freshest thing the project has to say
108
+ // is weeks old, nothing here is a next step. Measured on this repo 2026-09-21: 15 notes,
109
+ // 10 to 16 days old, all of them the generated session-end boilerplate, and the item they
110
+ // contributed quotes `bash tests/run-all.sh`, which this repo does not have. Bound is the
111
+ // exit handoff's own expiry: the note rides in on that row, so outliving it is incoherent.
112
+ if (Date.now() - newest.mtime > maxAgeMs) return null;
113
+
114
+ let text;
115
+ try {
116
+ text = readFileSync(join(tasksDir, newest.name), 'utf8');
117
+ } catch {
118
+ return null;
119
+ }
120
+
121
+ const lines = text.split('\n');
122
+ let title = '';
123
+ for (const line of lines) {
124
+ if (/^#\s+/.test(line)) {
125
+ title = line.replace(TITLE_PREFIX_RE, '').replace(/^#\s+/, '').trim();
126
+ break;
127
+ }
128
+ }
129
+
130
+ const items = [];
131
+ for (const heading of REMAINING_HEADINGS) {
132
+ const start = lines.findIndex((l) => heading.test(l.trim()));
133
+ if (start === -1) continue;
134
+ // A line is not an item. Markdown here comes in two shapes and hard-wrapping makes
135
+ // them look alike: a LIST, where each marker starts a new entry, and a PARAGRAPH,
136
+ // where a single sentence is split across physical lines. Treating every line as an
137
+ // item shreds one generated note's four-line Resume sentence into four fragments, each
138
+ // beginning mid-clause. So a marker opens an entry and everything up to the next marker
139
+ // or blank line is folded into it.
140
+ let current = '';
141
+ const flush = () => {
142
+ const item = current.trim();
143
+ if (item) items.push(truncate(item, MAX_ITEM_CHARS));
144
+ current = '';
145
+ };
146
+ for (let i = start + 1; i < lines.length && items.length < maxItems; i++) {
147
+ const raw = lines[i];
148
+ if (/^#{1,6}\s/.test(raw)) break; // next heading ends the section
149
+ const line = raw.trim();
150
+ if (!line) {
151
+ flush();
152
+ continue;
153
+ }
154
+ if (LIST_MARKER_RE.test(line)) {
155
+ flush();
156
+ current = line.replace(LIST_MARKER_RE, '').trim();
157
+ } else {
158
+ current = current ? `${current} ${line}` : line;
159
+ }
160
+ }
161
+ if (items.length < maxItems) flush();
162
+ if (items.length > 0) break;
163
+ }
164
+
165
+ if (items.length === 0) return null; // a note with nothing left to do is not a next step
166
+ return { file: `tasks/${basename(newest.name)}`, title, items };
167
+ }
@@ -53,6 +53,13 @@ export const TEXT_FIELDS_BY_TABLE = {
53
53
  'unfinished',
54
54
  // Excluded:
55
55
  // key_files — JSON.stringify(array); pre-scrub elements at call site
56
+ // next_steps — same shape, same reason: JSON.stringify({file,title,items}),
57
+ // pre-scrubbed element-wise in buildAndSaveHandoff. Listing it
58
+ // here would let scrubSecrets rewrite the SERIALIZED JSON, and the
59
+ // renderer's JSON.parse sits behind a `catch {}` — so the whole
60
+ // Next steps section would disappear silently rather than fail.
61
+ // Named here because the pre-ship review found the column had been
62
+ // added without updating this block, which is the file's contract.
56
63
  // match_keywords — currently a space-joined plain string; keeping it
57
64
  // here would scrub safely, but the value is built from
58
65
  // tokenizeHandoff() output (alphanumeric tokens only),
@@ -9,7 +9,7 @@ import { readGitState } from './git-state.mjs';
9
9
  import { readProjectTasks } from './task-reader.mjs';
10
10
  import { recentPlans } from './plan-reader.mjs';
11
11
  import { isAdoptedHere } from './quiet-scope.mjs';
12
- import { HANDOFF_EXPIRY_EXIT } from './handoff-constants.mjs';
12
+ import { HANDOFF_EXPIRY_EXIT, UNCONSUMED_HANDOFF_SQL } from './handoff-constants.mjs';
13
13
 
14
14
  function ageStr(ms) {
15
15
  const diff = Date.now() - ms;
@@ -32,7 +32,7 @@ function readRecentHandoff(db, project) {
32
32
  .prepare(
33
33
  `
34
34
  SELECT created_at_epoch, working_on FROM session_handoffs
35
- WHERE project = ? AND type = 'exit' AND created_at_epoch > ?
35
+ WHERE project = ? AND type = 'exit' AND created_at_epoch > ? AND ${UNCONSUMED_HANDOFF_SQL}
36
36
  ORDER BY created_at_epoch DESC LIMIT 1
37
37
  `,
38
38
  )
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.9.1",
3
+ "version": "6.10.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.9.1",
9
+ "version": "6.10.0",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.9.1",
3
+ "version": "6.10.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -69,6 +69,7 @@
69
69
  "lib/cli-flags.mjs",
70
70
  "lib/git-state.mjs",
71
71
  "lib/task-reader.mjs",
72
+ "lib/paused-reader.mjs",
72
73
  "lib/plan-reader.mjs",
73
74
  "lib/startup-dashboard.mjs",
74
75
  "lib/doctor-benchmark.mjs",
package/schema.mjs CHANGED
@@ -191,6 +191,12 @@ export const CURRENT_SCHEMA_VERSION = 49;
191
191
  // pragma_table_info on a missing table returns zero rows (it does not throw), so
192
192
  // naming any column of the new table is a table-presence check.
193
193
  const LATEST_MIGRATION_COLUMNS = [
194
+ // No version tag: this one ships WITHOUT a CURRENT_SCHEMA_VERSION bump on purpose (see
195
+ // the ALTER's note). It is listed here for exactly the reason the list exists — to force
196
+ // the migration pass on a DB whose version row already says "done" — and listing it is
197
+ // what makes the ALTER reachable at all. Pinned by the legacy-upgrade case in
198
+ // tests/handoff-consume.test.mjs rather than left as an assertion in a comment.
199
+ { table: 'session_handoffs', column: 'consumed_at' },
194
200
  { table: 'observations', column: 'last_access_session_id' }, // v48
195
201
  { table: 'observations', column: 'decay_seen_at_first_cite' }, // v46
196
202
  { table: 'citation_surface_log', column: 'surface' }, // v45
@@ -576,6 +582,36 @@ export function initSchema(db) {
576
582
  if (!handoffCols.includes('git_sha_at_handoff')) {
577
583
  db.exec(`ALTER TABLE session_handoffs ADD COLUMN git_sha_at_handoff TEXT DEFAULT NULL`);
578
584
  }
585
+ // Injecting a handoff now STAMPS it (hook-handoff.mjs::consumeHandoff) where it used to
586
+ // DELETE the row, so the row survives for the expiry GC to reap on age and stays
587
+ // available to anyone auditing what was injected. Additive + nullable: a legacy row
588
+ // reads NULL, which is exactly "not yet consumed".
589
+ //
590
+ // No CURRENT_SCHEMA_VERSION bump, deliberately. The version row is what locks an older
591
+ // code home out of this DB permanently, and nothing here needs that: the forced-migration
592
+ // probe below (LATEST_MIGRATION_COLUMNS) already makes this ALTER reachable on a DB whose
593
+ // version row says 49/done, which is the whole reason that list exists. An older build
594
+ // opening this DB keeps working — it simply deletes handoffs the way it always did.
595
+ if (!handoffCols.includes('consumed_at')) {
596
+ db.exec(`ALTER TABLE session_handoffs ADD COLUMN consumed_at INTEGER DEFAULT NULL`);
597
+ }
598
+ // Tree state at handoff time. `git_sha_at_handoff` has been captured since v25 but was
599
+ // never rendered into the injection, and the sha alone does not answer the question a
600
+ // resuming session actually asks first ("which branch, and is the tree dirty?") — so the
601
+ // other two fields of the readGitState call that was already being made are stored too.
602
+ if (!handoffCols.includes('git_branch')) {
603
+ db.exec(`ALTER TABLE session_handoffs ADD COLUMN git_branch TEXT DEFAULT NULL`);
604
+ }
605
+ if (!handoffCols.includes('git_dirty_count')) {
606
+ db.exec(`ALTER TABLE session_handoffs ADD COLUMN git_dirty_count INTEGER DEFAULT NULL`);
607
+ }
608
+ // The remaining work a paused note spells out (lib/paused-reader.mjs). Kept out of
609
+ // `unfinished`, which renders as "Recent activity" and mixes in-flight edits with
610
+ // surfaced errors — calling a hand-written remaining-work list "recent activity" would
611
+ // mislabel the one field in this row that a human actually wrote.
612
+ if (!handoffCols.includes('next_steps')) {
613
+ db.exec(`ALTER TABLE session_handoffs ADD COLUMN next_steps TEXT DEFAULT NULL`);
614
+ }
579
615
  } catch {
580
616
  /* non-critical — migration retries on next open */
581
617
  }
package/source-files.mjs CHANGED
@@ -66,6 +66,7 @@ export const SOURCE_FILES = [
66
66
  'lib/activity.mjs',
67
67
  'lib/cli-flags.mjs',
68
68
  'lib/task-reader.mjs',
69
+ 'lib/paused-reader.mjs',
69
70
  'lib/plan-reader.mjs',
70
71
  'lib/git-state.mjs',
71
72
  'lib/startup-dashboard.mjs',