claude-mem-lite 6.9.1 → 6.10.1

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.1",
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.1",
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, scrubFilePaths } 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,28 +324,122 @@ 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
 
269
- // 6. Match keywords
270
- const allText = [workingOn, ...completed.map((c) => c.title).filter(Boolean), unfinished].join(' ');
271
- const keywords = extractMatchKeywords(allText, [...fileSet]);
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 many of
376
+ // the secret patterns carry a value class that does not exclude `/` (count and
377
+ // population: lib/scrub-record.mjs), so a whole-path scrub eats the separator and
378
+ // destroys the filename. That is the named
379
+ // mechanism this repo grew for exactly this shape; the prose fields below are prose
380
+ // and correctly take the plain scrub.
381
+ file: scrubFilePath(String(note.file)),
382
+ title: scrubSecrets(String(note.title)),
383
+ items: note.items.map((i) => scrubSecrets(String(i))),
384
+ });
385
+ }
386
+ } catch {
387
+ /* best-effort, like the task reader above — never block the handoff */
388
+ }
389
+
390
+ // 6. Match keywords.
391
+ //
392
+ // Scrubbed at the DERIVATION, and the scrubbed file array is derived ONCE and feeds both
393
+ // sinks (here and key_files below) — the shape hook-llm.mjs uses where one path array
394
+ // reaches two columns. lib/scrub-record.mjs excludes match_keywords from scrubRecord, and
395
+ // the reason it recorded — "built from tokenizeHandoff() output (alphanumeric tokens
396
+ // only), so secrets cannot survive the upstream tokenizer" — does not hold on either arm:
397
+ // the FILE arm never reaches the tokenizer (it takes basename-minus-extension straight off
398
+ // this set, which holds RAW paths), and the tokenizer SPLITS a secret from its keyword
399
+ // rather than removing it, so `token=ghp_…` contributes `ghp_…` as a term of its own.
400
+ //
401
+ // Exposure, measured rather than asserted: nothing renders this column and no export face
402
+ // reads the table — `EXPORT_COLUMNS` is observations-only and `session_handoffs` has zero
403
+ // occurrences in server.mjs and the CLI. So this is local-DB-at-rest, with no egress path.
404
+ // A credential in a stored column is still worth removing; it is not a disclosure. (The
405
+ // sentence this replaces claimed egress through `export` and was false — a replacement
406
+ // justification written while retracting another one, unverified, which is this repo's
407
+ // signature recurrence. Pre-ship claims lens.)
408
+ //
409
+ // Per ELEMENT, then join — never scrub the concatenation. A credential noun ending one
410
+ // element and a `=`/`:` opening the next form a match that exists in NEITHER, and the
411
+ // derived term set then loses a word both columns keep (measured: `zebrafish`). Same rule
412
+ // next_steps and key_files already follow, and the same rule the truncation note below
413
+ // states. NOT identity on ordinary prose, which an earlier draft of this comment claimed:
414
+ // `api_key: handling` loses `handling` (5 of 6 ordinary developer prompts in a directed
415
+ // grid lose exactly one term). What is true, and is the actual justification, is that the
416
+ // term set now AGREES with what the resuming session is shown — `working_on` / `completed`
417
+ // / `unfinished` lose the same word through scrubRecord below. No value is scrubbed twice:
418
+ // these elements and the columns below are separate derivations from one raw source, each
419
+ // scrubbed once, which is the distinction D#46 is open about.
420
+ const safeFiles = scrubFilePaths([...fileSet]);
421
+ // The nullish guard mirrors what join() already did with a nullish element. Without it
422
+ // String(undefined) would put the literal token "undefined" into the term set — a behaviour
423
+ // change smuggled in by the per-element rewrite rather than chosen.
424
+ const allText = [workingOn, ...completed.map((c) => c.title).filter(Boolean), unfinished]
425
+ .map((t) => (t === null || t === undefined ? '' : scrubSecrets(String(t))))
426
+ .join(' ');
427
+ const keywords = extractMatchKeywords(allText, safeFiles);
272
428
 
273
429
  // T10d: capture HEAD sha so detectContinuationIntent can anchor on it later.
274
430
  // Best-effort — failures (non-git dir, missing binary, timeout) yield null.
275
431
  let gitShaAtHandoff = null;
432
+ let gitBranch = null;
433
+ let gitDirtyCount = null;
276
434
  try {
277
- gitShaAtHandoff = gitStateModule.readGitState({ cwd: process.cwd() }).headSha || null;
435
+ const st = gitStateModule.readGitState({ cwd: process.cwd() });
436
+ gitShaAtHandoff = st.headSha || null;
437
+ gitBranch = st.branch || null;
438
+ // 0 and NULL are different answers here: "measured, and the tree is clean" versus "no
439
+ // measurement happened". readGitState returns an empty `changed` for BOTH a clean repo
440
+ // and a directory that is not a repo at all, so the sha/branch decide which one it was.
441
+ // Collapsing them would let a handoff written outside a repo claim a clean tree.
442
+ gitDirtyCount = st.headSha || st.branch ? st.changed.length : null;
278
443
  } catch {
279
444
  /* swallow — handoff must still persist */
280
445
  }
@@ -295,14 +460,37 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
295
460
  working_on: workingOn,
296
461
  completed: completed.map((c) => `[${c.type}] ${c.title}`).join('\n'),
297
462
  unfinished,
298
- key_decisions: decisions.map((d) => d.title).join('\n'),
463
+ key_decisions: decisions.map((d) => `[${d.type}] ${d.title}`).join('\n'),
299
464
  match_keywords: keywords,
300
465
  });
301
- const safeKeyFiles = JSON.stringify([...fileSet].slice(0, 20).map((f) => scrubSecrets(String(f))));
466
+ // scrubFilePath, not scrubSecrets — the same correction next_steps.file took above, and
467
+ // key_files was the last of the six path columns still taking the whole-string form. It
468
+ // was already element-wise, which is what made it look compliant with this module's
469
+ // prescription; the function was the wrong one. Many SECRET_PATTERNS have a value class
470
+ // that does not exclude `/` (count and population: lib/scrub-record.mjs), so a whole-path
471
+ // match eats the separator and the filename
472
+ // with it, and the renderer below emits `basename(f)` — `## Key Files` read `password=***`
473
+ // where the file was `notes.mjs`. Worse than a wrong name: fileSet is keyed on the RAW
474
+ // path, so two files under one credential-bearing directory survive dedup and then
475
+ // collapse onto the identical stored string.
476
+ // `safeFiles` was derived at the keywords block above, so both sinks see one scrubbed
477
+ // array rather than two independent scrubs of the same paths. Slicing after the map is
478
+ // equivalent to mapping after the slice (per-element, order-preserving) and keeps the
479
+ // keyword arm on the FULL set, which is what it read before.
480
+ const safeKeyFiles = JSON.stringify(safeFiles.slice(0, 20));
481
+ // The UPSERT below resets `consumed_at`. Rewriting a handoff makes it fresh again, so it
482
+ // must become injectable again: the DELETE that consumeHandoff replaced did this
483
+ // implicitly (row gone, next build INSERTed a new one), while the UPSERT reuses the row.
484
+ // Without the reset, a session whose handoff was consumed by a sibling stays permanently
485
+ // invisible to injection however much work it does afterwards. Pre-ship defect lens.
486
+ //
487
+ // This prose lives here and not in the SQL because a backtick inside a SQL comment inside
488
+ // a template literal ends the literal — the same defect this repo shipped at v6.9.1, and
489
+ // it recurred right here while writing this fix.
302
490
  db.prepare(
303
491
  `
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
492
+ 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)
493
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
306
494
  ON CONFLICT(project, type, session_id) DO UPDATE SET
307
495
  working_on = excluded.working_on,
308
496
  completed = excluded.completed,
@@ -311,7 +499,11 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
311
499
  key_decisions = excluded.key_decisions,
312
500
  match_keywords = excluded.match_keywords,
313
501
  created_at_epoch = excluded.created_at_epoch,
314
- git_sha_at_handoff = excluded.git_sha_at_handoff
502
+ git_sha_at_handoff = excluded.git_sha_at_handoff,
503
+ git_branch = excluded.git_branch,
504
+ git_dirty_count = excluded.git_dirty_count,
505
+ next_steps = excluded.next_steps,
506
+ consumed_at = NULL
315
507
  `,
316
508
  ).run(
317
509
  project,
@@ -327,6 +519,9 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
327
519
  safe.match_keywords,
328
520
  Date.now(),
329
521
  gitShaAtHandoff,
522
+ gitBranch,
523
+ gitDirtyCount,
524
+ nextSteps,
330
525
  );
331
526
  }
332
527
 
@@ -371,6 +566,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
371
566
  `
372
567
  SELECT created_at_epoch, match_keywords FROM session_handoffs
373
568
  WHERE project = ? AND git_sha_at_handoff = ? AND (type = 'exit' OR session_id = ?)
569
+ AND ${UNCONSUMED_HANDOFF_SQL}
374
570
  ORDER BY created_at_epoch DESC LIMIT 1
375
571
  `,
376
572
  )
@@ -379,7 +575,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
379
575
  .prepare(
380
576
  `
381
577
  SELECT created_at_epoch, match_keywords FROM session_handoffs
382
- WHERE project = ? AND git_sha_at_handoff = ?
578
+ WHERE project = ? AND git_sha_at_handoff = ? AND ${UNCONSUMED_HANDOFF_SQL}
383
579
  ORDER BY created_at_epoch DESC LIMIT 1
384
580
  `,
385
581
  )
@@ -405,7 +601,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
405
601
  .prepare(
406
602
  `
407
603
  SELECT created_at_epoch, match_keywords FROM session_handoffs
408
- WHERE project = ? AND type = 'clear' AND session_id = ?
604
+ WHERE project = ? AND type = 'clear' AND session_id = ? AND ${UNCONSUMED_HANDOFF_SQL}
409
605
  ORDER BY created_at_epoch DESC LIMIT 1
410
606
  `,
411
607
  )
@@ -414,7 +610,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
414
610
  .prepare(
415
611
  `
416
612
  SELECT created_at_epoch, match_keywords FROM session_handoffs
417
- WHERE project = ? AND type = 'clear'
613
+ WHERE project = ? AND type = 'clear' AND ${UNCONSUMED_HANDOFF_SQL}
418
614
  ORDER BY created_at_epoch DESC LIMIT 1
419
615
  `,
420
616
  )
@@ -455,6 +651,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
455
651
  SELECT type, match_keywords, created_at_epoch FROM session_handoffs
456
652
  WHERE project = ?
457
653
  AND ((type = 'clear' AND session_id = ?) OR type = 'exit')
654
+ AND ${UNCONSUMED_HANDOFF_SQL}
458
655
  ORDER BY created_at_epoch DESC
459
656
  `,
460
657
  )
@@ -463,7 +660,7 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
463
660
  .prepare(
464
661
  `
465
662
  SELECT type, match_keywords, created_at_epoch FROM session_handoffs
466
- WHERE project = ? ORDER BY created_at_epoch DESC
663
+ WHERE project = ? AND ${UNCONSUMED_HANDOFF_SQL} ORDER BY created_at_epoch DESC
467
664
  `,
468
665
  )
469
666
  .all(project);
@@ -518,6 +715,7 @@ export function pickHandoffToInject(db, project, currentCcSessionId = null) {
518
715
  SELECT * FROM session_handoffs
519
716
  WHERE project = ?
520
717
  AND ((type = 'clear' AND session_id = ?) OR (type = 'exit' AND session_id != ?))
718
+ AND ${UNCONSUMED_HANDOFF_SQL}
521
719
  ORDER BY created_at_epoch DESC LIMIT 5
522
720
  `,
523
721
  )
@@ -526,7 +724,7 @@ export function pickHandoffToInject(db, project, currentCcSessionId = null) {
526
724
  .prepare(
527
725
  `
528
726
  SELECT * FROM session_handoffs
529
- WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 5
727
+ WHERE project = ? AND ${UNCONSUMED_HANDOFF_SQL} ORDER BY created_at_epoch DESC LIMIT 5
530
728
  `,
531
729
  )
532
730
  .all(project);
@@ -539,12 +737,97 @@ export function pickHandoffToInject(db, project, currentCcSessionId = null) {
539
737
  );
540
738
  }
541
739
 
740
+ /**
741
+ * Mark one handoff row as delivered.
742
+ *
743
+ * Replaces the DELETE that used to follow injection. Two things that DELETE cost: a handoff
744
+ * injected at a moment the model could not act on it was gone for good, and the row behind a
745
+ * bad injection no longer existed by the time anyone went looking for it. Marking keeps the
746
+ * row until the existing age-based GC in hook.mjs's auto-maintain reaps it, so retention is
747
+ * unchanged in the limit — only the window in which it can be read back grows.
748
+ *
749
+ * Scoped to the exact PK so a parallel session's handoff is untouched (the DELETE this
750
+ * replaces already had that property, and pre-v2.46 not having it made the DB forgetful).
751
+ *
752
+ * @param {Database} db Opened main database
753
+ * @param {{project: string, type: string, session_id: string}} handoff Row from pickHandoffToInject
754
+ * @param {number} [now=Date.now()] Injected for tests
755
+ * @returns {number} Rows changed (0 when a concurrent session consumed it first)
756
+ */
757
+ export function consumeHandoff(db, handoff, now = Date.now()) {
758
+ if (!handoff) return 0;
759
+ // `consumed_at IS NULL` in the WHERE, not just the SET: two sessions can race to inject
760
+ // the same exit handoff, and the first stamp is the one that should stand.
761
+ const res = db
762
+ .prepare(
763
+ `UPDATE session_handoffs SET consumed_at = ?
764
+ WHERE project = ? AND type = ? AND session_id = ? AND ${UNCONSUMED_HANDOFF_SQL}`,
765
+ )
766
+ .run(now, handoff.project, handoff.type, handoff.session_id);
767
+ return res.changes;
768
+ }
769
+
542
770
  export function renderHandoffInjection(db, project, currentCcSessionId = null) {
543
771
  const handoff = pickHandoffToInject(db, project, currentCcSessionId);
544
772
  if (!handoff) return null;
545
773
  return renderHandoffFromRow(handoff, db, project);
546
774
  }
547
775
 
776
+ // Markdown ATX markers carried by replayed text, at a token boundary.
777
+ //
778
+ // This block frames itself with `## Working On` / `## Completed` / `## Next steps`, and
779
+ // working_on is user prompt text — a prompt that opens with its own outline flattens into
780
+ // the block carrying `#` and `##` of its own, on the same line, because working_on joins up
781
+ // to five prompts with ` → `. A real injection read
782
+ // `## Working On` / `# 自主端到端测试与修复循环 ## 角色与授权 …`, at which point the
783
+ // block's structure and the replayed text's structure are indistinguishable to whatever
784
+ // reads it next. Same class as the authority-tag defanging one level down: a forged
785
+ // SECTION rather than a forged tag.
786
+ //
787
+ // Only a marker followed by whitespace, at a token boundary, counts — `#42`, `C#` and
788
+ // `D#216` are ordinary in this project's prose and survive untouched.
789
+ const ATX_HEADING_RE = /(^|\s)#{1,6}\s/g;
790
+ const ATX_MAX_PASSES = 32;
791
+
792
+ /**
793
+ * Strip ATX markers to a FIXPOINT, not in one pass.
794
+ *
795
+ * The gap is two characters wide and it is the same one format-utils' `defangToFixpoint`
796
+ * documents for the tag half: `(^|\s)` CONSUMES the boundary, so after removing the first
797
+ * `## ` the regex resumes past the second one and leaves it live. `## ## Key Decisions`
798
+ * came out of a single pass as a real `## Key Decisions` section inside the block — the
799
+ * precise property this defanging exists to hold, defeated by two extra characters. Found
800
+ * by the pre-ship defect lens; reproduced end-to-end before the fix.
801
+ *
802
+ * TERMINATION: every match contains at least one `#` and the replacement drops all of them,
803
+ * so any pass that changes the string removes at least one `#`. Self-bounded by the number
804
+ * of `#` in the input, and bounded again by the constant.
805
+ *
806
+ * INERT AT ANY DEPTH: still changing at the cap (≥32 nested forged layers, not reachable by
807
+ * accident) → drop every remaining `#`. Lossier, but the return value then provably carries
808
+ * no marker, which is the property callers rely on. Same fail-closed shape as the sibling.
809
+ */
810
+ function stripAtxToFixpoint(s) {
811
+ let text = s;
812
+ for (let pass = 0; pass < ATX_MAX_PASSES; pass++) {
813
+ const next = text.replace(ATX_HEADING_RE, '$1');
814
+ if (next === text) return text;
815
+ text = next;
816
+ }
817
+ return text.replace(/#/g, '');
818
+ }
819
+
820
+ /** Defang authority tags AND section markers, for any replayed free text. */
821
+ function safeText(value) {
822
+ return stripAtxToFixpoint(neutralizeContextDelimiters(String(value)));
823
+ }
824
+
825
+ // `[bugfix] title` → `title`. Both `completed` and `key_decisions` store the observation
826
+ // type this way; the bracket run is length-capped so a title that merely opens with a
827
+ // bracket ("[WIP] ..." is not a type) is not silently truncated to nothing.
828
+ const TYPE_PREFIX_RE = /^\[[^\]]{1,20}\]\s*/;
829
+ const titleOf = (line) => line.replace(TYPE_PREFIX_RE, '').trim();
830
+
548
831
  function renderHandoffFromRow(handoff, db, project) {
549
832
  const ageSec = Math.round((Date.now() - handoff.created_at_epoch) / 1000);
550
833
  const ageStr =
@@ -572,16 +855,66 @@ function renderHandoffFromRow(handoff, db, project) {
572
855
  // user prompt or edit snippet carrying a literal </session-handoff> would otherwise
573
856
  // close the block early and the rest would read as a real user message.
574
857
  if (handoff.working_on) {
575
- lines.push('## Working On', neutralizeContextDelimiters(handoff.working_on), '');
858
+ lines.push('## Working On', safeText(handoff.working_on), '');
576
859
  }
577
- if (handoff.completed) {
578
- lines.push(
579
- '## Completed',
580
- ...neutralizeContextDelimiters(handoff.completed)
860
+
861
+ // Tree state. The sha has been stored since v25 but was only ever an INPUT to the
862
+ // continuation anchor — it was never shown, so a resumed session opened by running
863
+ // `git status` / `git rev-parse` to find out where it stood. Rendered right after the
864
+ // objective, because "which branch, and is the tree dirty" is the next question after
865
+ // "what was I doing". Branch names are defanged for the same reason key_files basenames
866
+ // are: git ref names admit angle brackets, and this text is replayed into the prompt.
867
+ // A 7-char sha cannot carry a complete tag, so it is sliced rather than scrubbed.
868
+ const treeBits = [];
869
+ if (handoff.git_branch) treeBits.push(`branch ${safeText(handoff.git_branch)}`);
870
+ if (handoff.git_sha_at_handoff) treeBits.push(`@ ${String(handoff.git_sha_at_handoff).slice(0, 7)}`);
871
+ if (typeof handoff.git_dirty_count === 'number') {
872
+ // NULL stays silent: a row written before this shipped, or written outside a repo, has
873
+ // no measurement, and "clean" would be a claim nobody made.
874
+ treeBits.push(handoff.git_dirty_count === 0 ? 'clean' : `${handoff.git_dirty_count} uncommitted file(s)`);
875
+ }
876
+ if (treeBits.length > 0) lines.push('## Tree state', treeBits.join(' · '), '');
877
+
878
+ // Key Decisions is computed here, before Completed renders, because Completed is deduped
879
+ // against it. Measured on the live corpus 2026-09-21 with the real predicates, population
880
+ // = every project with observations: 35 of 35 rendered Key Decisions lines were
881
+ // byte-identical to a Completed line, in all 8 projects. The overlap only became visible
882
+ // once the payload fix landed — before that both sections were empty.
883
+ //
884
+ // One-directional on purpose, and this is the F4 ruling rather than a preference:
885
+ // `completed` is the session's OWN HISTORY, `key_decisions` is standing policy replayed to
886
+ // a LATER session, which is why only the latter filters superseded_at. So the line to drop
887
+ // is the duplicate in history, and the one case where the two genuinely differ — a
888
+ // retracted decision, absent from key_decisions — is exactly the case where nothing is
889
+ // dropped and the history keeps it. Render-time only: the stored row is untouched, so all
890
+ // three F4 guards in tests/audit-silent-20260814.test.mjs still read what they pinned.
891
+ const decisionLines = handoff.key_decisions
892
+ ? safeText(handoff.key_decisions)
581
893
  .split('\n')
582
- .map((l) => `- ${l}`),
583
- '',
584
- );
894
+ .filter((l) => l.trim())
895
+ : [];
896
+ // Match on the WHOLE line. Matching on the stripped title collapsed two DIFFERENT
897
+ // observations that share a title but not a type — `[change] 更新测试` vanished from
898
+ // Completed because `[decision] 更新测试` was standing policy — and that false match was
899
+ // permanent while the thing it accommodated is not. Pre-ship defect lens.
900
+ const decisionWhole = new Set(decisionLines);
901
+ // The accommodation, scoped to the rows that actually need it: a row written before
902
+ // key_decisions carried the `[type]` prefix holds a bare title, so only those get the
903
+ // looser title-only match. They age out at the handoff's own expiry.
904
+ const legacyTitles = new Set(decisionLines.filter((l) => !TYPE_PREFIX_RE.test(l)).map((l) => titleOf(l)));
905
+ const isDuplicateOfDecision = (line) => decisionWhole.has(line) || legacyTitles.has(titleOf(line));
906
+
907
+ if (handoff.completed) {
908
+ const kept = safeText(handoff.completed)
909
+ .split('\n')
910
+ .filter((l) => l.trim() && !isDuplicateOfDecision(l));
911
+ // An empty `## Completed` header is worse than no header — TWO of the eight measured
912
+ // projects had a session whose entire history was its decisions (dev--daagu and
913
+ // scratchpad--loop-smoke; the next closest keeps 2 lines). The commit body and an
914
+ // earlier draft of this comment said three; re-derived at both the 105- and
915
+ // 107-observation corpus states it is two. No type tags are lost with the header:
916
+ // key_decisions carries them now too.
917
+ if (kept.length > 0) lines.push('## Completed', ...kept.map((l) => `- ${l}`), '');
585
918
  }
586
919
  if (handoff.unfinished) {
587
920
  // Extract only the pending-work portion (before narrative history separator).
@@ -592,7 +925,7 @@ function renderHandoffFromRow(handoff, db, project) {
592
925
  if (pending) {
593
926
  lines.push(
594
927
  '## Recent activity',
595
- ...neutralizeContextDelimiters(pending)
928
+ ...safeText(pending)
596
929
  .split('; ')
597
930
  .map((l) => `- ${l}`),
598
931
  '',
@@ -606,17 +939,32 @@ function renderHandoffFromRow(handoff, db, project) {
606
939
  // (Linux allows almost any char but '/'), and this is the one field in this block
607
940
  // that was rendered raw while working_on/unfinished/key_decisions all neutralize.
608
941
  if (files.length > 0)
609
- lines.push('## Key Files', neutralizeContextDelimiters(files.map((f) => basename(f)).join(', ')), '');
942
+ lines.push('## Key Files', safeText(files.map((f) => basename(f)).join(', ')), '');
610
943
  } catch {}
611
944
  }
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
- );
945
+ // Next steps, from the project's newest paused note. Placed after Key Files and before
946
+ // Key Decisions: it is the most actionable block here, and it cites its own source file
947
+ // so the resuming session can open the full note instead of trusting this summary.
948
+ // Defanged like every other free-text field — the note is repo text, replayed verbatim
949
+ // into the prompt, and a literal closer would end the block early.
950
+ if (handoff.next_steps) {
951
+ try {
952
+ const note = JSON.parse(handoff.next_steps);
953
+ if (Array.isArray(note?.items) && note.items.length > 0) {
954
+ lines.push('## Next steps');
955
+ const from = note.file ? ` (from ${safeText(String(note.file))})` : '';
956
+ if (note.title) lines.push(`${safeText(String(note.title))}${from}`);
957
+ else if (from) lines.push(from.trim());
958
+ for (const item of note.items) lines.push(`- ${safeText(String(item))}`);
959
+ lines.push('');
960
+ }
961
+ } catch {
962
+ /* malformed JSON — skip, same as key_files */
963
+ }
964
+ }
965
+
966
+ if (decisionLines.length > 0) {
967
+ lines.push('## Key Decisions', ...decisionLines.map((l) => `- ${l}`), '');
620
968
  }
621
969
 
622
970
  lines.push('</session-handoff>');
@@ -660,10 +1008,9 @@ function renderHandoffFromRow(handoff, db, project) {
660
1008
  // Defang: these come from session_summaries, populated by Haiku OR by
661
1009
  // extractStructuredSummary over the assistant transcript tail — replayed text that can
662
1010
  // 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)}`);
1011
+ if (summary.completed) lines.push(safeText(summary.completed));
1012
+ if (summary.remaining_items) lines.push(`Remaining: ${safeText(summary.remaining_items)}`);
1013
+ if (summary.next_steps) lines.push(`Next steps: ${safeText(summary.next_steps)}`);
667
1014
  lines.push('</session-summary>');
668
1015
  }
669
1016
  } 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,175 @@
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 thin, but quote BOTH numbers, because the lifetime
11
+ // average hides a trend and a single average was the original justification: 15 of 318 rows
12
+ // non-empty over the corpus lifetime (4.7%), and 4 of the newest 20 (20%). So the honest
13
+ // form is "unreliable", not "does not work" — the recent regime is four times the average.
14
+ // The reason to read a FILE instead is not the rate anyway: a paused note is written by a
15
+ // human on purpose and names its own verify command, which is a different kind of signal
16
+ // from a field an LLM fills in when it happens to.
17
+ //
18
+ // The pre-ship claims lens raised this, reporting 4 of the newest 5 non-empty; that specific
19
+ // figure did not reproduce (measured 1 of 5, 4 of 20). The caveat stood, its number did not.
20
+ //
21
+ // A leaf on purpose — `fs`/`path` only, no package imports and no edge back into the hook
22
+ // layer, so importing it costs nothing at load time (same reason lib/data-paths.mjs is a
23
+ // leaf). Every failure mode (missing dir, unreadable file, binary content, races between
24
+ // readdir and stat) yields null or fewer items, never a throw: this runs inside handoff
25
+ // construction, which must still persist a row.
26
+ import { readdirSync, readFileSync, statSync } from 'fs';
27
+ import { join, basename } from 'path';
28
+ // The one non-builtin import, and it does not cost the leaf property: format-utils' only
29
+ // edge is lib/time-constants.mjs, itself importless. Reused rather than reimplemented
30
+ // because a raw slice gets two things wrong that this function already documents — it
31
+ // leaves no mark that the text was cut, and it can split a UTF-16 surrogate pair and emit
32
+ // a lone surrogate.
33
+ import { truncate } from '../format-utils.mjs';
34
+ // Also importless — the handoff policy module. One number for "how old is too old" rather
35
+ // than a second constant that can drift away from it.
36
+ import { HANDOFF_EXPIRY_EXIT } from './handoff-constants.mjs';
37
+
38
+ // Headings whose body is REMAINING WORK, in priority order — the first one present wins,
39
+ // regardless of where it sits in the document. "Resume" is last because the generated notes
40
+ // use it for a generic instruction, which is worth something but less than an explicit list.
41
+ // Deliberately NOT matched: "Last mutation tool call(s)" and the completed sections, whose
42
+ // bullets read like work items and are the opposite of work items.
43
+ const REMAINING_HEADINGS = [
44
+ /^#{1,6}\s*(?:未完成|待办|剩余(?:工作)?)\s*$/i,
45
+ /^#{1,6}\s*(?:not\s+done|remaining(?:\s+work)?|todo|next\s+steps?)\s*$/i,
46
+ /^#{1,6}\s*(?:resume|恢复)\s*$/i,
47
+ ];
48
+
49
+ // "# Paused — X" / "# 暂停 — X" / "# Paused: X" → X. A note whose title is only the word
50
+ // keeps the whole line rather than becoming empty.
51
+ const TITLE_PREFIX_RE = /^#{1,6}\s*(?:paused|暂停)\s*(?:[—–\-::]\s*)?/i;
52
+ const LIST_MARKER_RE = /^(?:[-*+]|\d+[.)])\s+/;
53
+ const MAX_ITEM_CHARS = 200;
54
+ // The read is synchronous and sits on the Stop path, once per assistant turn. Output was
55
+ // already bounded (5 items x 200 chars) and the INPUT was not — a note is prose a human
56
+ // wrote, so anything past this is not a note. Pre-ship defect lens.
57
+ const MAX_NOTE_BYTES = 256 * 1024;
58
+
59
+ /**
60
+ * Default project root, with test containment on the same channel lib/resolve-data-dir.mjs
61
+ * uses (audit 2026-08-22 P2-4).
62
+ *
63
+ * Under vitest the default resolves to the maintainer's real repo, which carries fifteen
64
+ * `tasks/*-paused.md` files of its own — so every suite that builds a handoff would quietly
65
+ * write this repo's paused note into the row under test. That was measured, not imagined:
66
+ * a probe over an unstubbed buildAndSaveHandoff came back carrying
67
+ * `tasks/session-end-b3c0c20d-paused.md`.
68
+ *
69
+ * Per-file `vi.spyOn` stubs fix the same leak but are discipline, not structure — nothing
70
+ * goes red when a new suite forgets one, which is how the sibling readers (readGitState,
71
+ * readProjectTasks) still leak into the files that do not stub them. Neutralising the
72
+ * DEFAULT instead makes the leak impossible while leaving an explicit `projectPath`
73
+ * untouched, so this module's own tests still read their temp dirs.
74
+ *
75
+ * The var is inherited by spawned hooks too, so e2e subprocesses are contained as well.
76
+ */
77
+ function defaultProjectPath() {
78
+ return process.env.CLAUDE_MEM_TEST_GUARD === '1' ? null : process.cwd();
79
+ }
80
+
81
+ /**
82
+ * Read the newest paused note for a project.
83
+ *
84
+ * @param {object} [options]
85
+ * @param {string} [options.projectPath] Absolute path to the project root. Defaults to the
86
+ * current working directory, and to nothing at all under the test guard.
87
+ * @param {number} [options.maxItems=5] Cap on returned items.
88
+ * @param {number} [options.maxAgeMs=HANDOFF_EXPIRY_EXIT] Ignore notes older than this.
89
+ * @returns {{file: string, title: string, items: string[]}|null} null when there is no note,
90
+ * the newest one is stale, or it has nothing left to do.
91
+ */
92
+ export function readPausedNote({
93
+ projectPath = defaultProjectPath(),
94
+ maxItems = 5,
95
+ maxAgeMs = HANDOFF_EXPIRY_EXIT,
96
+ } = {}) {
97
+ if (!projectPath) return null;
98
+ const tasksDir = join(projectPath, 'tasks');
99
+ let newest = null;
100
+ try {
101
+ for (const name of readdirSync(tasksDir)) {
102
+ if (!name.endsWith('-paused.md')) continue;
103
+ try {
104
+ const st = statSync(join(tasksDir, name));
105
+ if (st.size > MAX_NOTE_BYTES) continue;
106
+ if (!newest || st.mtimeMs > newest.mtime) newest = { name, mtime: st.mtimeMs };
107
+ } catch {
108
+ /* vanished between readdir and stat — skip it */
109
+ }
110
+ }
111
+ } catch {
112
+ return null; // no tasks/ dir
113
+ }
114
+ if (!newest) return null;
115
+ // Staleness bound, on the NEWEST note only — if the freshest thing the project has to say
116
+ // is weeks old, nothing here is a next step. Measured on this repo 2026-09-21: 15 notes,
117
+ // 10 to 16 days old, all of them the generated session-end boilerplate, and the item they
118
+ // contributed quotes `bash tests/run-all.sh`, which this repo does not have. Bound is the
119
+ // exit handoff's own expiry: the note rides in on that row, so outliving it is incoherent.
120
+ if (Date.now() - newest.mtime > maxAgeMs) return null;
121
+
122
+ let text;
123
+ try {
124
+ text = readFileSync(join(tasksDir, newest.name), 'utf8');
125
+ } catch {
126
+ return null;
127
+ }
128
+
129
+ const lines = text.split('\n');
130
+ let title = '';
131
+ for (const line of lines) {
132
+ if (/^#\s+/.test(line)) {
133
+ title = line.replace(TITLE_PREFIX_RE, '').replace(/^#\s+/, '').trim();
134
+ break;
135
+ }
136
+ }
137
+
138
+ const items = [];
139
+ for (const heading of REMAINING_HEADINGS) {
140
+ const start = lines.findIndex((l) => heading.test(l.trim()));
141
+ if (start === -1) continue;
142
+ // A line is not an item. Markdown here comes in two shapes and hard-wrapping makes
143
+ // them look alike: a LIST, where each marker starts a new entry, and a PARAGRAPH,
144
+ // where a single sentence is split across physical lines. Treating every line as an
145
+ // item shreds one generated note's four-line Resume sentence into four fragments, each
146
+ // beginning mid-clause. So a marker opens an entry and everything up to the next marker
147
+ // or blank line is folded into it.
148
+ let current = '';
149
+ const flush = () => {
150
+ const item = current.trim();
151
+ if (item) items.push(truncate(item, MAX_ITEM_CHARS));
152
+ current = '';
153
+ };
154
+ for (let i = start + 1; i < lines.length && items.length < maxItems; i++) {
155
+ const raw = lines[i];
156
+ if (/^#{1,6}\s/.test(raw)) break; // next heading ends the section
157
+ const line = raw.trim();
158
+ if (!line) {
159
+ flush();
160
+ continue;
161
+ }
162
+ if (LIST_MARKER_RE.test(line)) {
163
+ flush();
164
+ current = line.replace(LIST_MARKER_RE, '').trim();
165
+ } else {
166
+ current = current ? `${current} ${line}` : line;
167
+ }
168
+ }
169
+ if (items.length < maxItems) flush();
170
+ if (items.length > 0) break;
171
+ }
172
+
173
+ if (items.length === 0) return null; // a note with nothing left to do is not a next step
174
+ return { file: `tasks/${basename(newest.name)}`, title, items };
175
+ }
@@ -17,6 +17,13 @@
17
17
  // .files_modified / .files_read, observation_files.filename and events
18
18
  // .file_paths all stored raw paths while the title DERIVED FROM THE SAME PATH
19
19
  // was scrubbed. A prescription in a comment is not a mechanism; the helper is.
20
+ //
21
+ // Second axis, found after D#44 closed: that one compliant call site was compliant
22
+ // in SHAPE only. It mapped `scrubSecrets` over the elements — element-wise, as
23
+ // prescribed — and so read as the model the other five were measured against, while
24
+ // taking the whole-string function this module exists to keep off a path. "Follows the
25
+ // rule" and "calls the helper" are different claims, and only the second is checkable.
26
+ // Which is why the prescription above names `scrubFilePaths` rather than describing it.
20
27
 
21
28
  import { scrubSecrets } from '../secret-scrub.mjs';
22
29
 
@@ -52,13 +59,33 @@ export const TEXT_FIELDS_BY_TABLE = {
52
59
  'completed',
53
60
  'unfinished',
54
61
  // Excluded:
55
- // key_files — JSON.stringify(array); pre-scrub elements at call site
56
- // match_keywords — currently a space-joined plain string; keeping it
57
- // here would scrub safely, but the value is built from
58
- // tokenizeHandoff() output (alphanumeric tokens only),
59
- // so secrets cannot survive the upstream tokenizer.
60
- // Excluded to avoid double-work + future-proof against
61
- // a refactor that switches to JSON.stringify.
62
+ // key_files — JSON.stringify(array); pre-scrubbed at the call site via
63
+ // `scrubFilePaths`, NOT a bare per-element scrubSecrets. It held
64
+ // filesystem paths and took the whole-string function through
65
+ // v6.10.0, which is the defect scrubFilePath's own docblock
66
+ // describes; the renderer emits basename(), so the destroyed
67
+ // filename was what the resuming session was shown.
68
+ // next_steps — same shape, same reason: JSON.stringify({file,title,items}),
69
+ // pre-scrubbed element-wise in buildAndSaveHandoff. Listing it
70
+ // here would let scrubSecrets rewrite the SERIALIZED JSON, and the
71
+ // renderer's JSON.parse sits behind a `catch {}` — so the whole
72
+ // Next steps section would disappear silently rather than fail.
73
+ // Named here because the pre-ship review found the column had been
74
+ // added without updating this block, which is the file's contract.
75
+ // match_keywords — currently a space-joined plain string. Excluded because the
76
+ // value is scrubbed at its DERIVATION in buildAndSaveHandoff
77
+ // (`scrubSecrets(allText)` + the shared `safeFiles` array), which
78
+ // also future-proofs against a refactor to JSON.stringify.
79
+ //
80
+ // RETRACTED, measured: the reason recorded here until v6.10.0 was
81
+ // "built from tokenizeHandoff() output (alphanumeric tokens only),
82
+ // so secrets cannot survive the upstream tokenizer." Neither half
83
+ // holds. extractMatchKeywords has TWO arms and the FILE arm never
84
+ // reaches the tokenizer at all — it takes basename-minus-extension
85
+ // off the raw path set. And the tokenizer splits a secret from its
86
+ // keyword rather than removing it: `token=ghp_…` yields `ghp_…` as
87
+ // a term of its own. A no-op justification is worse than no
88
+ // justification, because it retires the question.
62
89
  // key_decisions is kept: call site uses '\n'.join (plain string), and
63
90
  // decision titles can carry secrets verbatim (LLM output).
64
91
  'key_decisions',
@@ -93,8 +120,16 @@ export const TEXT_FIELDS_BY_TABLE = {
93
120
  */
94
121
  export function scrubFilePath(p) {
95
122
  // SEGMENT-WISE, and that is the whole point rather than a micro-optimisation.
96
- // Eight SECRET_PATTERNS carry a value class that does not exclude `/`
97
- // (secret-scrub.mjs:33/74/78/83/98/109/259/260). On prose that is correct; run
123
+ // Many SECRET_PATTERNS carry a value class that does not exclude `/`. This is the one place
124
+ // that number is stated, with its population, because it is GRID-DEPENDENT and five copies of
125
+ // a bare "eight" is how it went wrong: measured here, 11 of the 40 patterns match text
126
+ // spanning `/` on a 15-shape credential grid; the two v6.10.1 pre-ship lenses measured 12
127
+ // (1130 path probes) and 15 (26-shape grid), and 21 on a structural reading of the value
128
+ // classes. "Eight", carried since v6.8.2 with the enumeration
129
+ // secret-scrub.mjs:33/74/78/83/98/109/259/260, is an UNDER-count on every one of those
130
+ // populations — it omits at least the `Authorization:`, `AccountKey=`, `DATABASE_URL`/
131
+ // `SUPABASE_KEY` and db-connection-URL arms. The mechanism does not depend on the count.
132
+ // On prose the wide value class is correct; run
98
133
  // whole-path, the match eats the separator and everything after it, so
99
134
  // `/repo/token=<secret>/notes.mjs` became `/repo/token=***` — the filename
100
135
  // destroyed at WRITE time and unrecoverable, and every file under such a
@@ -102,16 +137,27 @@ export function scrubFilePath(p) {
102
137
  // recalled another file's observations). Splitting first bounds every pattern to
103
138
  // the segment it matched in.
104
139
  //
105
- // The trade, stated rather than glossed: a credential whose own syntax spans a
106
- // separator is no longer caught here — the Slack webhook path and the
107
- // `scheme://user:pass@host` arms both need their `/` characters. Those are URL
108
- // shapes, and these columns hold filesystem paths; `scrubSecrets` still runs
109
- // whole-string on every prose field, which is where a URL actually lands.
110
- // Preserving path structure wins because the path IS the recall key.
140
+ // A credential whose own syntax SPANS a separator — `scheme://user:pass@host`, the Slack
141
+ // webhook path, a `postgres://` connection string — needs its `/` characters to match at all,
142
+ // so segment-wise scrubbing cannot see it. That was accepted here until v6.10.1 on the stated
143
+ // reason "these columns hold filesystem paths", and the pre-ship review measured that premise
144
+ // FALSE: `lib/save-observation.mjs` filters `params.files` on `typeof f === 'string'` and
145
+ // nothing else, and `extractFilePaths` returns a URL verbatim from a `{path}` / `{filePath}`
146
+ // tool input under a `PostToolUse: *` matcher. A URL reaches these columns. Measured: four URL
147
+ // shapes v6.10.0 redacted were being stored verbatim.
111
148
  //
149
+ // So the value decides, not the column: when it carries `://` it is scrubbed whole-string, the
150
+ // only way those patterns match. NAMED COST, pinned in tests/secret-scrub-coverage.test.mjs: a
151
+ // URL whose credential sits in a path SEGMENT now loses its filename to the greedy value class,
152
+ // which is the thing segment-wise scrubbing exists to prevent, traded back on this one shape.
153
+ // Redacting a live credential wins over preserving a filename that is not a recall key. The
154
+ // guard is `scrubSecrets`-decided, not scheme-decided — a credential-free URL comes back
155
+ // byte-identical, so ordinary `https://` / `s3://` / `file://` values are untouched.
156
+ const s = String(p ?? '');
157
+ if (s.includes('://')) return scrubSecrets(s);
112
158
  // The capture group keeps the separators in the split output, so join()
113
159
  // reconstructs the original byte-for-byte when nothing matches.
114
- return String(p ?? '')
160
+ return s
115
161
  .split(/([/\\])/)
116
162
  .map((part) => (part === '/' || part === '\\' ? part : scrubSecrets(part)))
117
163
  .join('');
@@ -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.1",
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.1",
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.1",
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',