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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/README.zh-CN.md +1 -0
- package/hook-context.mjs +3 -1
- package/hook-handoff.mjs +339 -42
- package/hook-shared.mjs +1 -0
- package/hook.mjs +12 -8
- package/lib/git-state.mjs +4 -1
- package/lib/handoff-constants.mjs +13 -0
- package/lib/paused-reader.mjs +167 -0
- package/lib/scrub-record.mjs +7 -0
- package/lib/startup-dashboard.mjs +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/schema.mjs +36 -0
- package/source-files.mjs +1 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
808
|
+
lines.push('## Working On', safeText(handoff.working_on), '');
|
|
576
809
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
.
|
|
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
|
-
...
|
|
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',
|
|
892
|
+
lines.push('## Key Files', safeText(files.map((f) => basename(f)).join(', ')), '');
|
|
610
893
|
} catch {}
|
|
611
894
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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(
|
|
664
|
-
if (summary.remaining_items)
|
|
665
|
-
|
|
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
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
|
|
1978
|
-
//
|
|
1979
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/scrub-record.mjs
CHANGED
|
@@ -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
|
)
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
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
|
+
"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.
|
|
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
|
}
|