@lh8ppl/claude-memory-kit 0.1.1 → 0.2.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/README.md +8 -5
- package/bin/cmk-auto-extract.mjs +13 -0
- package/bin/cmk-capture-prompt.mjs +0 -0
- package/bin/cmk-capture-turn.mjs +0 -0
- package/bin/cmk-compress-session.mjs +31 -17
- package/bin/cmk-inject-context.mjs +12 -2
- package/bin/cmk-observe-edit.mjs +0 -0
- package/bin/cmk-weekly-curate.mjs +14 -2
- package/package.json +3 -2
- package/src/audit-log.mjs +6 -0
- package/src/auto-drain.mjs +59 -0
- package/src/auto-extract.mjs +117 -6
- package/src/auto-persona.mjs +544 -0
- package/src/bullet-lookup.mjs +59 -0
- package/src/capture-turn.mjs +54 -0
- package/src/compress-session.mjs +6 -8
- package/src/compressor.mjs +37 -22
- package/src/conflict-queue.mjs +8 -1
- package/src/daily-distill.mjs +19 -11
- package/src/doctor.mjs +79 -26
- package/src/forget.mjs +14 -0
- package/src/graduate-session.mjs +65 -0
- package/src/graduation.mjs +179 -0
- package/src/index-rebuild.mjs +26 -4
- package/src/inject-context.mjs +352 -65
- package/src/install.mjs +52 -7
- package/src/lessons-promote.mjs +137 -0
- package/src/mcp-server.mjs +17 -0
- package/src/memory-write.mjs +20 -7
- package/src/native-memory.mjs +98 -0
- package/src/persona-portability.mjs +253 -0
- package/src/provenance.mjs +23 -5
- package/src/read-hook-stdin.mjs +47 -0
- package/src/register-crons.mjs +17 -8
- package/src/sanitize.mjs +39 -0
- package/src/scratchpad.mjs +247 -19
- package/src/session-end-tasks.mjs +127 -0
- package/src/settings-hooks.mjs +33 -3
- package/src/spawn-bin.mjs +83 -0
- package/src/subcommands.mjs +472 -26
- package/src/weekly-curate.mjs +53 -6
- package/src/write-fact.mjs +60 -3
- package/template/.claude/skills/memory-write/SKILL.md +47 -88
- package/template/.gitignore.fragment +6 -0
- package/template/CLAUDE.md.template +17 -7
- package/template/local/machine-paths.md.template +1 -12
- package/template/local/overrides.md.template +1 -11
- package/template/project/MEMORY.md.template +5 -26
- package/template/project/SOUL.md.template +1 -10
- package/template/user/fragments/INDEX.md.template +1 -1
- package/template/.claude/hooks/pre-tool-memory.js +0 -78
- package/template/.claude/hooks/transcript-capture.js +0 -69
- package/template/.claude/settings.json +0 -27
- package/template/support/scripts/auto-extract-memory.sh +0 -102
- package/template/support/scripts/refresh-distill-timestamp.py +0 -35
- package/template/support/scripts/register-crons.py +0 -242
- package/template/support/scripts/run-daily-distill.sh +0 -67
- package/template/support/scripts/run-weekly-curate.sh +0 -58
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Hook-stdin drain (Task: cmk-compress-session manual-invocation hang fix).
|
|
2
|
+
//
|
|
3
|
+
// The SessionEnd hook bins (npm: packages/cli/bin/; plugin: plugin/bin/) drain
|
|
4
|
+
// stdin so Claude Code's hook pipe closes cleanly. The PAYLOAD is discarded —
|
|
5
|
+
// the bins read their state from disk (sessions/now.md, the fact corpus), not
|
|
6
|
+
// from the hook JSON — so the read exists ONLY to drain the pipe.
|
|
7
|
+
//
|
|
8
|
+
// The hazard this module fixes: `readFileSync(0, 'utf8')` BLOCKS until stdin
|
|
9
|
+
// reaches EOF. When the bin is run as a real hook, Claude Code pipes the JSON
|
|
10
|
+
// payload and closes the pipe → EOF arrives → the read returns instantly. But
|
|
11
|
+
// when the bin is run MANUALLY without redirecting stdin (e.g. the v0.2.0
|
|
12
|
+
// cut-gate B7 probe: `cmk-compress-session | Out-Null` pipes stdout but leaves
|
|
13
|
+
// stdin on the interactive console), the console never sends EOF, so the read
|
|
14
|
+
// blocks forever — before ANY of the bin's body runs. The 60s SessionEnd hook
|
|
15
|
+
// ceiling then kills it (exit 124, zero stderr), which looked for days like a
|
|
16
|
+
// graduation/Haiku/lock hang but was the wrapper never executing. See
|
|
17
|
+
// DECISION-LOG 2026-06-06 FIX/RESOLVED.
|
|
18
|
+
//
|
|
19
|
+
// Boundary: readHookStdin({ isTTY }) → string. When stdin is an interactive TTY
|
|
20
|
+
// (no piped payload to drain, and reading would block), return '' without
|
|
21
|
+
// touching the fd. Otherwise drain the fd as before. isTTY is INJECTED by the
|
|
22
|
+
// caller (`process.stdin.isTTY`) so the function is pure and unit-testable
|
|
23
|
+
// without a real terminal.
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Drain the hook payload from stdin without blocking an interactive console.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} [opts]
|
|
31
|
+
* @param {boolean} [opts.isTTY] - the caller's `process.stdin.isTTY`. When
|
|
32
|
+
* truthy, stdin is an interactive terminal: there is no piped payload and a
|
|
33
|
+
* blocking read would hang, so we return '' and never touch the fd.
|
|
34
|
+
* @param {number} [opts.fd=0] - the stdin file descriptor (override for tests).
|
|
35
|
+
* @returns {string} the drained payload, or '' for a TTY / unconnected stdin.
|
|
36
|
+
*/
|
|
37
|
+
export function readHookStdin({ isTTY, fd = 0 } = {}) {
|
|
38
|
+
// Interactive console → no payload to drain and readFileSync(fd) would block
|
|
39
|
+
// waiting for an EOF the terminal never sends. Treat as empty.
|
|
40
|
+
if (isTTY) return '';
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(fd, 'utf8');
|
|
43
|
+
} catch {
|
|
44
|
+
// stdin not connected (e.g. fd closed) — fine; the hook still proceeds.
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/register-crons.mjs
CHANGED
|
@@ -121,13 +121,17 @@ function buildWindowsSchtasks({ command, entryName, hour, minute, dayOfWeek }) {
|
|
|
121
121
|
// already exists (idempotency primitive). /RL LIMITED (not HIGHEST)
|
|
122
122
|
// because daily distill doesn't need admin.
|
|
123
123
|
const time = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
124
|
+
// The command is wrapped in `/TR "${command}"`. cmd.exe/schtasks treats
|
|
125
|
+
// backslash as a PATH separator, not an escape character, so the only
|
|
126
|
+
// char that can break out of the quotes is a literal `"` — and that is
|
|
127
|
+
// rejected at the registerCron boundary (see the validation below), the
|
|
128
|
+
// same way an embedded `'` is rejected for the Linux cron line. So no
|
|
129
|
+
// escaping is applied here. (The earlier `command.replace(/"/g, '\\"')`
|
|
130
|
+
// was both unnecessary — controlled bin-name+path commands never contain
|
|
131
|
+
// `"` — and CodeQL-flagged js/incomplete-sanitization, since it didn't
|
|
132
|
+
// escape backslashes; but `\"`/`\\` is not how cmd.exe quotes anyway, and
|
|
133
|
+
// doubling backslashes would corrupt Windows paths. Reject-at-boundary is
|
|
134
|
+
// the correct, safe contract.)
|
|
131
135
|
// Task 34: /SC WEEKLY /D <SUN|MON|...> for weekly cadence; /SC DAILY otherwise.
|
|
132
136
|
let scheduleFlags;
|
|
133
137
|
if (dayOfWeek !== undefined && dayOfWeek !== null) {
|
|
@@ -139,7 +143,7 @@ function buildWindowsSchtasks({ command, entryName, hour, minute, dayOfWeek }) {
|
|
|
139
143
|
} else {
|
|
140
144
|
scheduleFlags = '/SC DAILY';
|
|
141
145
|
}
|
|
142
|
-
return `schtasks /Create /TN "${entryName}" ${scheduleFlags} /ST ${time} /TR "${
|
|
146
|
+
return `schtasks /Create /TN "${entryName}" ${scheduleFlags} /ST ${time} /TR "${command}" /RL LIMITED /F`;
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
/**
|
|
@@ -165,6 +169,11 @@ export function registerCron(opts = {}) {
|
|
|
165
169
|
// cron command needs to either escape POSIX-style ('\'') or
|
|
166
170
|
// we extend this helper with a sanitizer (v0.1.x candidate).
|
|
167
171
|
errors.push("command: must not contain single quotes (Linux cron-line shell-quoting contract)");
|
|
172
|
+
} else if (opts.command.includes('"')) {
|
|
173
|
+
// Windows schtasks /TR wraps the command in double-quotes; an embedded
|
|
174
|
+
// `"` would break out of the quoting. Reject at the boundary (cmd.exe
|
|
175
|
+
// has no usable in-quote escape for `"`), mirroring the `'` contract.
|
|
176
|
+
errors.push('command: must not contain double quotes (Windows schtasks /TR quoting contract)');
|
|
168
177
|
}
|
|
169
178
|
const entryName = opts.entryName ?? CRON_ENTRY_NAME;
|
|
170
179
|
if (!entryName || typeof entryName !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(entryName)) {
|
package/src/sanitize.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// sanitize.mjs — privacy sanitizers applied before durable writes to a
|
|
2
|
+
// committed/shared tier. Sibling to poison-guard.mjs, but where Poison_Guard
|
|
3
|
+
// REJECTS a write (secrets/poison), these REWRITE it (privacy abstraction).
|
|
4
|
+
//
|
|
5
|
+
// Write-path fix #1 (the self-test privacy leak): a durable fact written to a
|
|
6
|
+
// committed project tier carried the local username inside an absolute
|
|
7
|
+
// interpreter path (C:\Users\<you>\...\python.exe), shipping it to git and
|
|
8
|
+
// making the fact non-portable. sanitizeHomePaths abstracts the home-dir
|
|
9
|
+
// prefix to `~` — killing the username leak AND making the fact portable
|
|
10
|
+
// across machines — while preserving everything after the home dir.
|
|
11
|
+
//
|
|
12
|
+
// Applied to P (committed) and U (cross-project) tier writes. NOT to L
|
|
13
|
+
// (local, gitignored) — machine-specific absolute paths are the whole point
|
|
14
|
+
// of the local tier, so they stay verbatim there.
|
|
15
|
+
|
|
16
|
+
// Each pattern matches an absolute home-directory prefix up to (but not
|
|
17
|
+
// including) the next path separator / whitespace / quote, so the remainder
|
|
18
|
+
// of the path is preserved. Username char class excludes separators, spaces,
|
|
19
|
+
// quotes, and shell/redirect metacharacters.
|
|
20
|
+
const USER = "[^\\\\/\\s\"'`<>|]+";
|
|
21
|
+
// Case-INSENSITIVE: Windows + macOS filesystems are case-insensitive, so a
|
|
22
|
+
// fact may carry `c:\users\you\…` or `/users/you`; the `i` flag keeps the
|
|
23
|
+
// privacy abstraction from being bypassed by lowercasing.
|
|
24
|
+
const HOME_PATH_PATTERNS = [
|
|
25
|
+
new RegExp(`[A-Za-z]:[\\\\/]Users[\\\\/]${USER}`, 'gi'), // Windows C:\Users\name (either slash)
|
|
26
|
+
new RegExp(`/Users/${USER}`, 'gi'), // macOS /Users/name
|
|
27
|
+
new RegExp(`/home/${USER}`, 'gi'), // Linux /home/name
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Abstract absolute home-directory prefixes to `~`. Returns non-string input
|
|
32
|
+
* unchanged (callers may pass undefined for optional fields).
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeHomePaths(text) {
|
|
35
|
+
if (typeof text !== 'string') return text;
|
|
36
|
+
let out = text;
|
|
37
|
+
for (const re of HOME_PATH_PATTERNS) out = out.replace(re, '~');
|
|
38
|
+
return out;
|
|
39
|
+
}
|
package/src/scratchpad.mjs
CHANGED
|
@@ -17,7 +17,14 @@
|
|
|
17
17
|
// this module will call instead. The handoff is clean: format stays identical;
|
|
18
18
|
// only the location of the formatter moves.
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
appendFileSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
21
28
|
import { generateId } from '@lh8ppl/cmk-canonicalize';
|
|
22
29
|
import {
|
|
23
30
|
VALID_TIERS,
|
|
@@ -28,7 +35,8 @@ import {
|
|
|
28
35
|
} from './tier-paths.mjs';
|
|
29
36
|
import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
|
|
30
37
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
31
|
-
import { writeBullet, parseBulletProvenance } from './provenance.mjs';
|
|
38
|
+
import { writeBullet, parseBulletProvenance, isProvenanceCommentLine } from './provenance.mjs';
|
|
39
|
+
import { graduateForCapRelief } from './graduation.mjs';
|
|
32
40
|
|
|
33
41
|
const VALID_TRUST = new Set(['high', 'medium', 'low']);
|
|
34
42
|
const VALID_WRITE_SOURCES = new Set([
|
|
@@ -188,9 +196,33 @@ function insertIntoSection(text, sectionTitle, bullet) {
|
|
|
188
196
|
return lines.join('\n');
|
|
189
197
|
}
|
|
190
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Ensure a `## <sectionTitle>` heading exists in a scratchpad file, creating
|
|
201
|
+
* it (appended at EOF, with clean spacing) if absent. Used by the persona
|
|
202
|
+
* promoter (Task 64 / F2) so a cross-project candidate routed to a sane-but-
|
|
203
|
+
* new section lands instead of schema-failing to the review queue. The CALLER
|
|
204
|
+
* owns the name-safety guard — this only writes the heading.
|
|
205
|
+
*
|
|
206
|
+
* @returns {{created: boolean, error?: string}}
|
|
207
|
+
*/
|
|
208
|
+
export function ensureSectionExists(scratchpadPath, sectionTitle) {
|
|
209
|
+
if (!existsSync(scratchpadPath)) return { created: false, error: 'no-file' };
|
|
210
|
+
const text = readFileSync(scratchpadPath, 'utf8');
|
|
211
|
+
if (findSectionRange(text.split('\n'), sectionTitle)) return { created: false };
|
|
212
|
+
const body = text.trimEnd(); // drop trailing whitespace/blank lines (no `\s+$` regex — trips ReDoS heuristics)
|
|
213
|
+
// No leading blank lines for an empty/whitespace-only file (the scaffolded
|
|
214
|
+
// scratchpads are never empty, but keep the output clean if one ever is).
|
|
215
|
+
const prefix = body ? `${body}\n\n` : '';
|
|
216
|
+
writeFileSync(scratchpadPath, `${prefix}## ${sectionTitle}\n`, 'utf8');
|
|
217
|
+
return { created: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const EVICTED_ID_RE = /^- \(([PUL]-[A-Za-z0-9]+)\)/;
|
|
221
|
+
|
|
191
222
|
function consolidate(text, { nowDate }) {
|
|
192
223
|
const lines = text.split('\n');
|
|
193
224
|
const removeIdx = new Set();
|
|
225
|
+
const evicted = [];
|
|
194
226
|
const staleCutoff = new Date(nowDate.getTime() - STALE_AFTER_DAYS * 24 * 60 * 60 * 1000);
|
|
195
227
|
let bulletsRemoved = 0;
|
|
196
228
|
|
|
@@ -199,7 +231,7 @@ function consolidate(text, { nowDate }) {
|
|
|
199
231
|
const bulletLine = lines[i];
|
|
200
232
|
const commentLine = lines[i + 1];
|
|
201
233
|
if (!bulletLine.startsWith('- (')) continue;
|
|
202
|
-
if (!
|
|
234
|
+
if (!isProvenanceCommentLine(commentLine)) continue;
|
|
203
235
|
|
|
204
236
|
const prov = parseBulletProvenance(commentLine);
|
|
205
237
|
if (!prov || !prov.at || !prov.trust) continue;
|
|
@@ -211,14 +243,47 @@ function consolidate(text, { nowDate }) {
|
|
|
211
243
|
|
|
212
244
|
removeIdx.add(i);
|
|
213
245
|
removeIdx.add(i + 1);
|
|
246
|
+
// Task 91.2: capture the dropped bullet so the caller can ARCHIVE it
|
|
247
|
+
// (recoverable, per the §6.5 tombstone principle) instead of hard-deleting.
|
|
248
|
+
const idMatch = bulletLine.match(EVICTED_ID_RE);
|
|
249
|
+
evicted.push({ id: idMatch ? idMatch[1] : 'unknown', block: `${bulletLine}\n${commentLine}` });
|
|
214
250
|
bulletsRemoved++;
|
|
215
251
|
}
|
|
216
252
|
|
|
217
253
|
if (removeIdx.size === 0) {
|
|
218
|
-
return { text, bulletsRemoved: 0 };
|
|
254
|
+
return { text, bulletsRemoved: 0, evicted: [] };
|
|
219
255
|
}
|
|
220
256
|
const out = lines.filter((_, i) => !removeIdx.has(i)).join('\n');
|
|
221
|
-
return { text: out, bulletsRemoved };
|
|
257
|
+
return { text: out, bulletsRemoved, evicted };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Task 91.2: archive bullets that consolidate() dropped, so eviction is
|
|
261
|
+
// recoverable rather than silent (mirrors `cmk forget`'s tombstone, §6.5).
|
|
262
|
+
// Append-only log under the tier's archive dir; one audit entry per bullet.
|
|
263
|
+
const EVICTED_ARCHIVE_HEADER =
|
|
264
|
+
'# Evicted scratchpad bullets\n\n<!-- Bullets dropped by cap-consolidation (low/medium trust, >14 days old). Kept here so eviction is recoverable, not silent. To restore one, re-capture it via `cmk remember`. -->\n\n';
|
|
265
|
+
|
|
266
|
+
function archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted, now }) {
|
|
267
|
+
const archiveDir = join(tierRoot, 'memory', 'archive');
|
|
268
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
269
|
+
const archivePath = join(archiveDir, 'evicted-bullets.md');
|
|
270
|
+
const ts = now ?? nowIso();
|
|
271
|
+
const header = existsSync(archivePath) ? '' : EVICTED_ARCHIVE_HEADER;
|
|
272
|
+
const block = `## Evicted ${ts} — consolidate(${scratchpad})\n${evicted
|
|
273
|
+
.map((e) => e.block)
|
|
274
|
+
.join('\n')}\n\n`;
|
|
275
|
+
appendFileSync(archivePath, header + block, 'utf8');
|
|
276
|
+
for (const e of evicted) {
|
|
277
|
+
appendAuditEntry(tierRoot, {
|
|
278
|
+
ts,
|
|
279
|
+
action: 'evicted',
|
|
280
|
+
tier,
|
|
281
|
+
id: e.id,
|
|
282
|
+
reasonCode: REASON_CODES.SCRATCHPAD_EVICTED,
|
|
283
|
+
paths: { archive: archivePath },
|
|
284
|
+
extra: { scratchpad },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
222
287
|
}
|
|
223
288
|
|
|
224
289
|
export function appendScratchpadBullet(opts = {}) {
|
|
@@ -275,6 +340,7 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
275
340
|
// 2. Cap check: would the write push to >95%? If yes, consolidate.
|
|
276
341
|
let consolidationRan = false;
|
|
277
342
|
let bulletsConsolidated = 0;
|
|
343
|
+
let evictedBullets = [];
|
|
278
344
|
let finalContent = candidate;
|
|
279
345
|
const candidateBytes = Buffer.byteLength(candidate, 'utf8');
|
|
280
346
|
|
|
@@ -284,28 +350,72 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
284
350
|
const consolidated = consolidate(candidate, { nowDate });
|
|
285
351
|
bulletsConsolidated = consolidated.bulletsRemoved;
|
|
286
352
|
finalContent = consolidated.text;
|
|
353
|
+
evictedBullets = consolidated.evicted ?? [];
|
|
287
354
|
}
|
|
288
355
|
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
356
|
+
// 2b. Graduation (Task 91, generalized to all tiers by Task 94 / §19.2). If
|
|
357
|
+
// still over the LOAD-cap after stale-drop — which is what happens when the
|
|
358
|
+
// bullets are high-trust (consolidate() never drops those) — graduate the
|
|
359
|
+
// oldest high-trust bullets OUT of the hot index into the tier's permanent
|
|
360
|
+
// fact store, keeping the injected slice small (the write already succeeded
|
|
361
|
+
// via the load-cap; graduation is about injection budget, not write success).
|
|
362
|
+
// ALL FACT-BEARING TIERS (D-61): project (MEMORY.md + SOUL.md) AND the user-
|
|
363
|
+
// tier persona (USER/HABITS/LESSONS) — graduating into the tier's existing
|
|
364
|
+
// fact store (project context/memory/, user <userDir>/fragments/; writeFact
|
|
365
|
+
// already routes tier-U facts there). Local tier (machine-paths/overrides) is
|
|
366
|
+
// excluded: it's machine-specific config, not durable facts.
|
|
367
|
+
let bulletsGraduated = 0;
|
|
368
|
+
let graduatedIds = [];
|
|
369
|
+
let finalBytes = Buffer.byteLength(finalContent, 'utf8');
|
|
370
|
+
if (finalBytes > cap && (tier === 'P' || tier === 'U')) {
|
|
371
|
+
const grad = graduateForCapRelief({
|
|
372
|
+
text: finalContent,
|
|
373
|
+
capBytes: cap,
|
|
374
|
+
tier,
|
|
375
|
+
projectRoot,
|
|
376
|
+
userDir,
|
|
377
|
+
now,
|
|
303
378
|
});
|
|
379
|
+
finalContent = grad.text;
|
|
380
|
+
graduatedIds = grad.graduated;
|
|
381
|
+
bulletsGraduated = graduatedIds.length;
|
|
382
|
+
finalBytes = Buffer.byteLength(finalContent, 'utf8');
|
|
304
383
|
}
|
|
305
384
|
|
|
385
|
+
// 3. Load-cap, NOT write-cap (Task 94 / D-61 / design §19). The write ALWAYS
|
|
386
|
+
// succeeds — the cap governs only how much is injected, never whether content
|
|
387
|
+
// can be saved (the never-lose-memory invariant). When consolidate + graduation
|
|
388
|
+
// can't bring the file under cap (e.g. an absurdly small cap, or a single large
|
|
389
|
+
// bullet), the file is allowed to GROW past the inject budget; inject-context
|
|
390
|
+
// load-caps the snapshot (§7.1.1) and the overflow stays searchable on disk.
|
|
391
|
+
// The old `cap_exceeded` reject path was removed here — see §19.5 for the
|
|
392
|
+
// superseded write-cap design and why it changed.
|
|
393
|
+
|
|
306
394
|
// 4. Write + audit
|
|
307
395
|
writeFileSync(path, finalContent, 'utf8');
|
|
308
396
|
const ts = now ?? nowIso();
|
|
397
|
+
|
|
398
|
+
// 4a. Task 91.2 — archive evicted bullets now that the drop is durable on
|
|
399
|
+
// disk (only on the success path, so we never archive a bullet that's still
|
|
400
|
+
// live in the unchanged on-disk scratchpad).
|
|
401
|
+
if (evictedBullets.length > 0) {
|
|
402
|
+
archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted: evictedBullets, now: ts });
|
|
403
|
+
}
|
|
404
|
+
// 4b. Task 91.4 (Door 4) — one audit entry per graduated bullet, so the
|
|
405
|
+
// bullet→fact-file move is traceable in the audit log (writeFact also logs
|
|
406
|
+
// the fact create; this records the graduation that triggered it).
|
|
407
|
+
for (const gid of graduatedIds) {
|
|
408
|
+
appendAuditEntry(tierRoot, {
|
|
409
|
+
ts,
|
|
410
|
+
action: 'graduated',
|
|
411
|
+
tier,
|
|
412
|
+
id: gid,
|
|
413
|
+
reasonCode: REASON_CODES.SCRATCHPAD_GRADUATED,
|
|
414
|
+
paths: { after: path },
|
|
415
|
+
extra: { scratchpad },
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
309
419
|
appendAuditEntry(tierRoot, {
|
|
310
420
|
ts,
|
|
311
421
|
action: 'appended',
|
|
@@ -320,6 +430,7 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
320
430
|
bytes: finalBytes,
|
|
321
431
|
consolidationRan,
|
|
322
432
|
bulletsConsolidated,
|
|
433
|
+
bulletsGraduated,
|
|
323
434
|
},
|
|
324
435
|
});
|
|
325
436
|
|
|
@@ -331,5 +442,122 @@ export function appendScratchpadBullet(opts = {}) {
|
|
|
331
442
|
bytes: finalBytes,
|
|
332
443
|
consolidationRan,
|
|
333
444
|
bulletsConsolidated,
|
|
445
|
+
bulletsGraduated,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Proactive cap-relief for a SINGLE scratchpad, run OUTSIDE the append path
|
|
451
|
+
* (Task 94.3). The reactive relief inside appendScratchpadBullet only fires when
|
|
452
|
+
* a write triggers cap pressure; this lets a SessionEnd sweep keep the injected
|
|
453
|
+
* slice under its load-cap even in a read-only session (no new bullets) and catch
|
|
454
|
+
* low/medium bullets that AGED past the 14-day stale window between sessions.
|
|
455
|
+
*
|
|
456
|
+
* Runs the SAME relief sequence as the append path — consolidate (stale-drop +
|
|
457
|
+
* archive) then graduate (high-trust overflow → the tier's fact store) — but only
|
|
458
|
+
* when the scratchpad is already over its load-cap, and it writes back ONLY if
|
|
459
|
+
* the content actually changed (no churn / no audit noise on a comfortable
|
|
460
|
+
* scratchpad, satisfying the over-mutation guard). The WHOLE relief (consolidate
|
|
461
|
+
* AND graduate) is gated to fact-bearing tiers P + U; the L tier (machine config)
|
|
462
|
+
* is left untouched even when over cap — its bullets are not durable facts.
|
|
463
|
+
*
|
|
464
|
+
* Cost note (hook-ceiling composition): each graduated bullet flows through
|
|
465
|
+
* writeFact, which reindexes — so a sweep that graduates N bullets does N reindex
|
|
466
|
+
* passes. That mirrors the reactive append path's existing per-graduating-bullet
|
|
467
|
+
* cost; at SessionEnd it runs AFTER the ~50s concurrent Haiku block but is local
|
|
468
|
+
* file I/O (no spawn/network), and the SessionEnd hook is best-effort (exits 0 on
|
|
469
|
+
* overrun), so it does not threaten the 60s ceiling in practice.
|
|
470
|
+
*
|
|
471
|
+
* @returns {{action:'relieved'|'noop'|'skipped', reason?:string, tier:string,
|
|
472
|
+
* scratchpad:string, bulletsConsolidated:number, bulletsGraduated:number,
|
|
473
|
+
* graduatedIds:string[], bytes:number}}
|
|
474
|
+
*/
|
|
475
|
+
export function sweepScratchpadForCapRelief({
|
|
476
|
+
tier,
|
|
477
|
+
scratchpad,
|
|
478
|
+
projectRoot,
|
|
479
|
+
userDir,
|
|
480
|
+
now,
|
|
481
|
+
settings,
|
|
482
|
+
}) {
|
|
483
|
+
const base = {
|
|
484
|
+
tier,
|
|
485
|
+
scratchpad,
|
|
486
|
+
bulletsConsolidated: 0,
|
|
487
|
+
bulletsGraduated: 0,
|
|
488
|
+
graduatedIds: [],
|
|
489
|
+
bytes: 0,
|
|
490
|
+
};
|
|
491
|
+
const path = resolveScratchpadPath({ tier, scratchpad, projectRoot, userDir });
|
|
492
|
+
if (!existsSync(path)) {
|
|
493
|
+
return { ...base, action: 'skipped', reason: 'no-file' };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const original = readFileSync(path, 'utf8');
|
|
497
|
+
const cap = resolveCap({ tier, scratchpad, projectRoot, userDir, settings });
|
|
498
|
+
const originalBytes = Buffer.byteLength(original, 'utf8');
|
|
499
|
+
if (originalBytes <= cap) {
|
|
500
|
+
// Load-cap respected already — leave it alone (no churn).
|
|
501
|
+
return { ...base, action: 'noop', bytes: originalBytes };
|
|
502
|
+
}
|
|
503
|
+
// Relief (both consolidate AND graduate) applies only to fact-bearing tiers.
|
|
504
|
+
// Gate here so the consolidate stale-drop can never touch L-tier machine config
|
|
505
|
+
// even if a future caller passes it (graduateAllScratchpads never does today).
|
|
506
|
+
if (tier !== 'P' && tier !== 'U') {
|
|
507
|
+
return { ...base, action: 'noop', reason: 'tier-excluded', bytes: originalBytes };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Over cap — run the same relief sequence as appendScratchpadBullet.
|
|
511
|
+
const ts = now ?? nowIso();
|
|
512
|
+
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
513
|
+
|
|
514
|
+
const consolidated = consolidate(original, { nowDate: new Date(ts) });
|
|
515
|
+
let working = consolidated.text;
|
|
516
|
+
const evicted = consolidated.evicted ?? [];
|
|
517
|
+
|
|
518
|
+
let graduatedIds = [];
|
|
519
|
+
if (Buffer.byteLength(working, 'utf8') > cap) {
|
|
520
|
+
// tier is already guaranteed P||U by the gate above.
|
|
521
|
+
const grad = graduateForCapRelief({
|
|
522
|
+
text: working,
|
|
523
|
+
capBytes: cap,
|
|
524
|
+
tier,
|
|
525
|
+
projectRoot,
|
|
526
|
+
userDir,
|
|
527
|
+
now: ts,
|
|
528
|
+
});
|
|
529
|
+
working = grad.text;
|
|
530
|
+
graduatedIds = grad.graduated;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (working === original) {
|
|
534
|
+
// Nothing relievable (no stale bullets to drop; graduation infeasible or no
|
|
535
|
+
// eligible high-trust bullets). Load-cap means over-cap is allowed — leave
|
|
536
|
+
// the file untouched rather than rewriting it identically.
|
|
537
|
+
return { ...base, action: 'noop', reason: 'irreducible', bytes: originalBytes };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
writeFileSync(path, working, 'utf8');
|
|
541
|
+
if (evicted.length > 0) {
|
|
542
|
+
archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted, now: ts });
|
|
543
|
+
}
|
|
544
|
+
for (const gid of graduatedIds) {
|
|
545
|
+
appendAuditEntry(tierRoot, {
|
|
546
|
+
ts,
|
|
547
|
+
action: 'graduated',
|
|
548
|
+
tier,
|
|
549
|
+
id: gid,
|
|
550
|
+
reasonCode: REASON_CODES.SCRATCHPAD_GRADUATED,
|
|
551
|
+
paths: { after: path },
|
|
552
|
+
extra: { scratchpad, trigger: 'session-end' },
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
...base,
|
|
557
|
+
action: 'relieved',
|
|
558
|
+
bulletsConsolidated: evicted.length,
|
|
559
|
+
bulletsGraduated: graduatedIds.length,
|
|
560
|
+
graduatedIds,
|
|
561
|
+
bytes: Buffer.byteLength(working, 'utf8'),
|
|
334
562
|
};
|
|
335
563
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SessionEnd orchestrator (Task 86b + D-42). The shared brain behind both
|
|
2
|
+
// cmk-compress-session bins (npm: packages/cli/bin/; plugin: plugin/bin/) —
|
|
3
|
+
// extracted so the orchestration lives in ONE testable place instead of being
|
|
4
|
+
// duplicated across the twin bins (the twin-bin drift hazard CLAUDE.md warns
|
|
5
|
+
// about).
|
|
6
|
+
//
|
|
7
|
+
// What it does: at session end we run TWO independent Haiku passes —
|
|
8
|
+
// 1. compressSession — reads sessions/now.md (the session buffer) → writes
|
|
9
|
+
// sessions/today-{date}.md, truncates now.md, appends compress.log.
|
|
10
|
+
// 2. autoPersona — reads the context/memory/ fact corpus (written per-turn by
|
|
11
|
+
// auto-extract, NOT by compressSession) → promotes cross-project doctrine
|
|
12
|
+
// into the user-tier persona scratchpads.
|
|
13
|
+
//
|
|
14
|
+
// They have DISJOINT inputs and DISJOINT outputs (compress touches the project
|
|
15
|
+
// sessions/ tree; persona touches the user-tier scratchpads + audit.log; neither
|
|
16
|
+
// reads the other's writes; neither takes a lock the other needs). So we run them
|
|
17
|
+
// CONCURRENTLY via Promise.allSettled.
|
|
18
|
+
//
|
|
19
|
+
// Why concurrent, not sequential (the D-42 composition fix): each pass carries a
|
|
20
|
+
// 50s inner Haiku timeout, and the SessionEnd hook ceiling is 60s (design §8.5 /
|
|
21
|
+
// plugin/hooks/hooks.json). Run sequentially, the worst case is 50s + 50s = 100s
|
|
22
|
+
// — well over the ceiling, so the OS would kill the hook mid-persona-write,
|
|
23
|
+
// dropping {"continue": true} AND risking a half-written user-tier INDEX (HC-5
|
|
24
|
+
// corruption, shared across every project). Run concurrently, the wall-clock is
|
|
25
|
+
// max(50s, 50s) ≈ 50s — comfortably inside 60s. compressSession is correct alone
|
|
26
|
+
// (50<60); autoPersona is correct alone (50<60); only their SEQUENTIAL composition
|
|
27
|
+
// was broken. Concurrency is the composition fix.
|
|
28
|
+
//
|
|
29
|
+
// allSettled (not all): both passes are best-effort. A failure in one must never
|
|
30
|
+
// discard the other's result, and must never reject up into the hook (a thrown
|
|
31
|
+
// SessionEnd hook blocks the user from closing their terminal). Each pass gets its
|
|
32
|
+
// OWN backend instance (makeBackend factory) so there is zero shared mutable state
|
|
33
|
+
// across the two concurrent calls.
|
|
34
|
+
|
|
35
|
+
import { compressSession } from './compress-session.mjs';
|
|
36
|
+
import { autoPersona } from './auto-persona.mjs';
|
|
37
|
+
import { graduateAllScratchpads } from './graduate-session.mjs';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run the two independent SessionEnd Haiku passes concurrently.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* @param {string} opts.projectRoot - resolved project root (CMK_PROJECT_DIR or cwd).
|
|
44
|
+
* @param {string} opts.userDir - user-tier root (~/.claude-memory-kit or override).
|
|
45
|
+
* @param {() => object} opts.makeBackend - factory returning a fresh CompressorBackend
|
|
46
|
+
* per call (each concurrent pass gets its own instance — no shared state).
|
|
47
|
+
* @param {string} [opts.now] - ISO timestamp override (tests).
|
|
48
|
+
* @returns {Promise<{compressOutcome: PromiseSettledResult, personaOutcome: PromiseSettledResult, graduationOutcome: PromiseSettledResult}>}
|
|
49
|
+
*/
|
|
50
|
+
export async function runSessionEndTasks({ projectRoot, userDir, makeBackend, now }) {
|
|
51
|
+
const [compressOutcome, personaOutcome] = await Promise.allSettled([
|
|
52
|
+
compressSession({ projectRoot, backend: makeBackend(), now }),
|
|
53
|
+
// cooldownMs:0 — compressSession runs concurrently and would otherwise trip the
|
|
54
|
+
// shared 120s Haiku cooldown gate; at SessionEnd we explicitly want persona to run.
|
|
55
|
+
// source:'transcript' (Task 86c / D-44) — classify the RAW recent conversation,
|
|
56
|
+
// where standing-rule statements survive verbatim, NOT the distilled fact corpus
|
|
57
|
+
// (which strips the cross-project signal). This is what makes the cold-open work.
|
|
58
|
+
autoPersona({ projectRoot, userDir, backend: makeBackend(), cooldownMs: 0, now, source: 'transcript' }),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
// Task 94.3: proactive graduation sweep. SEQUENTIAL, AFTER the concurrent block —
|
|
62
|
+
// autoPersona WRITES the user-tier persona scratchpads and graduation READS+
|
|
63
|
+
// rewrites them, so they share inputs and must NOT overlap (the §6.8/§7.1
|
|
64
|
+
// disjoint-input rule). Running it here means the sweep sees the freshly-promoted
|
|
65
|
+
// persona, then trims any overflow so the next session's injected slice stays
|
|
66
|
+
// under its load-cap. Pure local file I/O (no Haiku/network) → adds <<1s, no
|
|
67
|
+
// hook-ceiling risk. Wrapped so a synchronous throw can't reject up into the hook.
|
|
68
|
+
let graduationOutcome;
|
|
69
|
+
try {
|
|
70
|
+
graduationOutcome = {
|
|
71
|
+
status: 'fulfilled',
|
|
72
|
+
value: graduateAllScratchpads({ projectRoot, userDir, now }),
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
graduationOutcome = { status: 'rejected', reason: err };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { compressOutcome, personaOutcome, graduationOutcome };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Render the two outcomes into stderr diagnostic lines (shared by both bins so
|
|
83
|
+
* the log shape can't drift between them). Pure — returns an array of lines, each
|
|
84
|
+
* already newline-terminated.
|
|
85
|
+
*
|
|
86
|
+
* @param {{compressOutcome: PromiseSettledResult, personaOutcome: PromiseSettledResult}} outcomes
|
|
87
|
+
* @returns {string[]}
|
|
88
|
+
*/
|
|
89
|
+
export function summarizeSessionEnd({ compressOutcome, personaOutcome, graduationOutcome }) {
|
|
90
|
+
const lines = [];
|
|
91
|
+
|
|
92
|
+
if (compressOutcome.status === 'fulfilled') {
|
|
93
|
+
const r = compressOutcome.value ?? {};
|
|
94
|
+
const reason = r.reason ? ` (${r.reason})` : '';
|
|
95
|
+
const bytes = r.bytesIn ? ` (in: ${r.bytesIn}b, out: ${r.bytesOut}b)` : '';
|
|
96
|
+
lines.push(`cmk-compress-session: ${r.action}${reason}${bytes} ms: ${r.duration_ms ?? 0}\n`);
|
|
97
|
+
} else {
|
|
98
|
+
const e = compressOutcome.reason;
|
|
99
|
+
lines.push(`cmk-compress-session: unexpected error: ${e?.message ?? e}\n`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (personaOutcome.status === 'fulfilled') {
|
|
103
|
+
const p = personaOutcome.value ?? {};
|
|
104
|
+
lines.push(
|
|
105
|
+
`cmk-compress-session: persona ${p.action} (promoted: ${p.promoted?.length ?? 0}, queued: ${p.queued?.length ?? 0})\n`,
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
const e = personaOutcome.reason;
|
|
109
|
+
lines.push(`cmk-compress-session: persona refresh failed: ${e?.message ?? e}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// graduationOutcome is optional so pre-94.3 callers (and the orchestrator tests
|
|
113
|
+
// that pass only the two Haiku outcomes) still render exactly two lines.
|
|
114
|
+
if (graduationOutcome) {
|
|
115
|
+
if (graduationOutcome.status === 'fulfilled') {
|
|
116
|
+
const g = graduationOutcome.value ?? {};
|
|
117
|
+
lines.push(
|
|
118
|
+
`cmk-compress-session: graduation (graduated: ${g.totalGraduated ?? 0}, consolidated: ${g.totalConsolidated ?? 0})\n`,
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
const e = graduationOutcome.reason;
|
|
122
|
+
lines.push(`cmk-compress-session: graduation failed: ${e?.message ?? e}\n`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
package/src/settings-hooks.mjs
CHANGED
|
@@ -34,9 +34,12 @@
|
|
|
34
34
|
//
|
|
35
35
|
// **Original block (pre-2026-05-29, repair.mjs)**: the PLUGIN form,
|
|
36
36
|
// `bash "${CLAUDE_PLUGIN_ROOT}/bin/<name>"`, 6 events incl. Setup →
|
|
37
|
-
// cmk-version-check. That form
|
|
38
|
-
//
|
|
39
|
-
//
|
|
37
|
+
// cmk-version-check. That form required bash to be present. It lives in
|
|
38
|
+
// plugin/hooks/hooks.json for the PLUGIN route (Route B) — and as of
|
|
39
|
+
// Task 62 (2026-05-31) that route was converted to the node form
|
|
40
|
+
// `node "${CLAUDE_PLUGIN_ROOT}/bin/<name>.mjs"`, so BOTH routes are now
|
|
41
|
+
// bash-free (node-only, cross-OS). version-check was ported to a node
|
|
42
|
+
// .mjs stub at that point (see below).
|
|
40
43
|
//
|
|
41
44
|
// **Task 49 (2026-05-29)**: the npm route (Route A) needs hooks that
|
|
42
45
|
// work with NO plugin loaded. This block is that form. It drops the
|
|
@@ -174,6 +177,33 @@ export function writeKitHooks(settingsPath) {
|
|
|
174
177
|
}
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
// Task 79 + 90: allow-list the kit's own surfaces so the agent's EXPLICIT
|
|
181
|
+
// captures stay seamless (the AUTO hook path already is). Two prompts to
|
|
182
|
+
// suppress, because Task 69 made the SKILL the capture delivery path:
|
|
183
|
+
// - `Bash(cmk:*)` (Task 79) — stops "Allow this bash command?" when the
|
|
184
|
+
// agent runs `cmk remember` / `cmk lessons promote` (prefix-wildcard;
|
|
185
|
+
// matches any `cmk <subcommand> …`).
|
|
186
|
+
// - `Skill(memory-write)` (Task 90) — stops "Use skill /memory-write?" when
|
|
187
|
+
// the model INVOKES the capture skill. The bash rule alone doesn't cover
|
|
188
|
+
// this: the skill-invocation gate is a separate Claude Code permission
|
|
189
|
+
// surface (`Skill(<name>)` rule, per code.claude.com/docs/en/permissions).
|
|
190
|
+
// Surfaced by the v0.2.0 cut-gate live run — the friction Task 79 killed
|
|
191
|
+
// returned one layer up once capture moved into the skill.
|
|
192
|
+
// Idempotent + over-mutation safe: preserve the user's existing allow entries;
|
|
193
|
+
// only append ours if absent.
|
|
194
|
+
const KIT_ALLOW = ['Bash(cmk:*)', 'Skill(memory-write)'];
|
|
195
|
+
if (!settings.permissions || typeof settings.permissions !== 'object') {
|
|
196
|
+
settings.permissions = {};
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(settings.permissions.allow)) {
|
|
199
|
+
settings.permissions.allow = [];
|
|
200
|
+
}
|
|
201
|
+
for (const rule of KIT_ALLOW) {
|
|
202
|
+
if (!settings.permissions.allow.includes(rule)) {
|
|
203
|
+
settings.permissions.allow.push(rule);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
177
207
|
const after = JSON.stringify(settings);
|
|
178
208
|
const changed = before !== after;
|
|
179
209
|
|