@lh8ppl/claude-memory-kit 0.1.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.
Files changed (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,327 @@
1
+ // install.mjs — implementation of `cmk install`.
2
+ //
3
+ // Public contract (tests assert this; internals can change freely):
4
+ //
5
+ // install({
6
+ // projectRoot, // <repo> root for project + local tiers
7
+ // userTier, // resolved user-tier path (defaults via resolveUserTier())
8
+ // force, // currently unused; reserved for Task 4 CLAUDE.md downgrade override
9
+ // dryRun, // currently unused; print-only mode reserved for Task 32
10
+ // }) → {
11
+ // projectRoot, // resolved
12
+ // userTier, // resolved
13
+ // created: string[], // absolute paths newly written
14
+ // skipped: string[], // absolute paths that already existed (untouched)
15
+ // gitignore: { action: 'created' | 'replaced' | 'unchanged', path: string },
16
+ // errors: { path: string, error: string }[],
17
+ // }
18
+ //
19
+ // Design notes:
20
+ // - Deep module: the boundary above is the only public surface. Internal
21
+ // helpers walk the kit's template/ tree, strip .template suffixes,
22
+ // and copy files. Tests verify the contract, not the internals.
23
+ // - Never overwrites existing files in the target. If MEMORY.md (or any
24
+ // other tier seed) already has user edits, we skip it and log to
25
+ // `skipped`. This is what makes re-installs safe.
26
+ // - The .gitignore block is delimited so re-runs refresh in place
27
+ // without duplicating lines and without touching unrelated entries.
28
+ // - In dev (running from the cloned repo), the kit's template/ lives
29
+ // at repo root. When packaged for npm publish (Task 36), template/
30
+ // ships inside @lh8ppl/claude-memory-kit — `resolveTemplateDir()`
31
+ // handles both.
32
+
33
+ import {
34
+ existsSync,
35
+ mkdirSync,
36
+ readFileSync,
37
+ readdirSync,
38
+ statSync,
39
+ writeFileSync,
40
+ copyFileSync,
41
+ } from 'node:fs';
42
+ import { homedir } from 'node:os';
43
+ import { dirname, join, relative, resolve } from 'node:path';
44
+ import { fileURLToPath } from 'node:url';
45
+ import { injectClaudeMdBlock } from './claude-md.mjs';
46
+
47
+ const __filename = fileURLToPath(import.meta.url);
48
+ const CLI_SRC_DIR = dirname(__filename);
49
+ // Walk up: packages/cli/src → packages/cli → packages → repo root
50
+ const REPO_ROOT_DEV = resolve(CLI_SRC_DIR, '..', '..', '..');
51
+ const CLI_PKG_DIR = resolve(CLI_SRC_DIR, '..');
52
+
53
+ const GITIGNORE_START = '# claude-memory-kit:gitignore:start v0.1.0';
54
+ const GITIGNORE_END = '# claude-memory-kit:gitignore:end';
55
+
56
+ /**
57
+ * Read the kit version from the cli package's package.json.
58
+ * Used as the default version for the CLAUDE.md marker.
59
+ */
60
+ export function getKitVersion() {
61
+ const pkg = JSON.parse(readFileSync(join(CLI_PKG_DIR, 'package.json'), 'utf8'));
62
+ return pkg.version;
63
+ }
64
+
65
+ /**
66
+ * Locate the kit's template/ directory.
67
+ *
68
+ * Two scenarios:
69
+ * 1. Dev (running from cloned repo): template/ at repo root.
70
+ * 2. Published (Task 36): template/ shipped inside the cli package.
71
+ *
72
+ * For v0.1.0 dev, scenario 1 is the only working path. Scenario 2 is
73
+ * handled with a fallback so a future npm-published install still works
74
+ * after Task 36 wires the publish step.
75
+ */
76
+ export function resolveTemplateDir() {
77
+ const devPath = join(REPO_ROOT_DEV, 'template');
78
+ if (existsSync(devPath) && statSync(devPath).isDirectory()) return devPath;
79
+
80
+ // Published-package fallback: template/ alongside the cli package's src.
81
+ const packagedPath = resolve(CLI_SRC_DIR, '..', 'template');
82
+ if (existsSync(packagedPath) && statSync(packagedPath).isDirectory()) return packagedPath;
83
+
84
+ throw new Error(
85
+ `cmk install: could not locate template/ (checked ${devPath} and ${packagedPath}). ` +
86
+ `If you are running from a checkout, ensure template/ exists at the repo root.`
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Resolve the user-tier path.
92
+ *
93
+ * Precedence:
94
+ * 1. $MEMORY_KIT_USER_DIR if set (any non-empty value).
95
+ * 2. ~/.claude-memory-kit/ (default).
96
+ *
97
+ * Per design §1.1: "User-tier path override: the user tier path defaults
98
+ * to ~/.claude-memory-kit/ but can be overridden via the MEMORY_KIT_USER_DIR
99
+ * environment variable."
100
+ */
101
+ export function resolveUserTier() {
102
+ const env = process.env.MEMORY_KIT_USER_DIR;
103
+ if (env && env.trim().length > 0) return env;
104
+ return join(homedir(), '.claude-memory-kit');
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ */
108
+ /* Internal helpers (not exported; tests don't depend on these names) */
109
+ /* ------------------------------------------------------------------ */
110
+
111
+ /**
112
+ * Walk a directory recursively, returning a list of file entries.
113
+ * Each entry: { absSrc, relPath, isGitkeep }
114
+ */
115
+ function walkFiles(rootDir) {
116
+ const out = [];
117
+ function recurse(current) {
118
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
119
+ const full = join(current, entry.name);
120
+ if (entry.isDirectory()) {
121
+ recurse(full);
122
+ } else if (entry.isFile()) {
123
+ out.push({
124
+ absSrc: full,
125
+ relPath: relative(rootDir, full).replace(/\\/g, '/'),
126
+ isGitkeep: entry.name === '.gitkeep',
127
+ });
128
+ }
129
+ }
130
+ }
131
+ recurse(rootDir);
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Compute the target file name from a kit-template source name.
137
+ * "SOUL.md.template" → "SOUL.md"
138
+ * "INDEX.md.template" → "INDEX.md"
139
+ * "machine-paths.md.template" → "machine-paths.md"
140
+ * (.gitkeep files are filtered out before this is called)
141
+ */
142
+ function targetName(srcName) {
143
+ if (srcName.endsWith('.template')) return srcName.slice(0, -'.template'.length);
144
+ return srcName;
145
+ }
146
+
147
+ /**
148
+ * Install one tier: copy every non-.gitkeep file from srcDir into destDir,
149
+ * stripping the .template suffix. Skips existing files.
150
+ *
151
+ * Side effects:
152
+ * - Creates destDir + any needed subdirs
153
+ * - Writes new files
154
+ * - Mutates the supplied `created` / `skipped` / `errors` arrays
155
+ */
156
+ function installTier(srcDir, destDir, { created, skipped, errors }) {
157
+ if (!existsSync(srcDir)) {
158
+ errors.push({ path: srcDir, error: 'template tier missing from kit' });
159
+ return;
160
+ }
161
+
162
+ // Ensure root destDir exists (covers fresh installs).
163
+ mkdirSync(destDir, { recursive: true });
164
+
165
+ for (const file of walkFiles(srcDir)) {
166
+ if (file.isGitkeep) {
167
+ // .gitkeep marks an empty kit dir; mirror just the directory in target.
168
+ const targetDir = join(destDir, dirname(file.relPath));
169
+ mkdirSync(targetDir, { recursive: true });
170
+ continue;
171
+ }
172
+
173
+ const targetRel = join(dirname(file.relPath), targetName(file.relPath.split('/').pop()));
174
+ const targetAbs = join(destDir, targetRel);
175
+
176
+ if (existsSync(targetAbs)) {
177
+ skipped.push(targetAbs);
178
+ continue;
179
+ }
180
+
181
+ try {
182
+ mkdirSync(dirname(targetAbs), { recursive: true });
183
+ copyFileSync(file.absSrc, targetAbs);
184
+ created.push(targetAbs);
185
+ } catch (err) {
186
+ errors.push({ path: targetAbs, error: err && err.message ? err.message : String(err) });
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Build the canonical .gitignore managed block from template/.gitignore.fragment.
193
+ * Adds start/end markers around the fragment so we can refresh in place.
194
+ */
195
+ function buildGitignoreBlock(templateDir) {
196
+ const fragmentPath = join(templateDir, '.gitignore.fragment');
197
+ const fragment = existsSync(fragmentPath)
198
+ ? readFileSync(fragmentPath, 'utf8').trim()
199
+ : 'context.local/\ncontext/.index/\ncontext/.locks/';
200
+ return `${GITIGNORE_START}\n${fragment}\n${GITIGNORE_END}\n`;
201
+ }
202
+
203
+ /**
204
+ * Inject (or refresh) the managed .gitignore block in `<projectRoot>/.gitignore`.
205
+ *
206
+ * Algorithm:
207
+ * - No .gitignore: create one containing only the managed block.
208
+ * - Has .gitignore, no markers: append the managed block at EOF.
209
+ * - Has .gitignore, markers present: replace the marker-delimited block
210
+ * in place (refresh). Everything outside the markers is byte-preserved.
211
+ *
212
+ * Returns: { action: 'created' | 'replaced' | 'unchanged', path: string }
213
+ */
214
+ function injectGitignore(projectRoot, block) {
215
+ const giPath = join(projectRoot, '.gitignore');
216
+ const startRe = /# claude-memory-kit:gitignore:start[^\n]*\n/;
217
+ const endRe = /# claude-memory-kit:gitignore:end\n?/;
218
+
219
+ if (!existsSync(giPath)) {
220
+ writeFileSync(giPath, block, 'utf8');
221
+ return { action: 'created', path: giPath };
222
+ }
223
+
224
+ const existing = readFileSync(giPath, 'utf8');
225
+ const startMatch = existing.match(startRe);
226
+ const endMatch = existing.match(endRe);
227
+
228
+ if (!startMatch || !endMatch || startMatch.index > endMatch.index) {
229
+ // No managed block (or markers malformed) — append.
230
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
231
+ writeFileSync(giPath, existing + sep + block, 'utf8');
232
+ return { action: 'created', path: giPath };
233
+ }
234
+
235
+ // Markers present — replace the slice between (and including) them.
236
+ const before = existing.slice(0, startMatch.index);
237
+ const after = existing.slice(endMatch.index + endMatch[0].length);
238
+ const next = before + block + after;
239
+
240
+ if (next === existing) {
241
+ return { action: 'unchanged', path: giPath };
242
+ }
243
+ writeFileSync(giPath, next, 'utf8');
244
+ return { action: 'replaced', path: giPath };
245
+ }
246
+
247
+ /* ------------------------------------------------------------------ */
248
+ /* Public entry point */
249
+ /* ------------------------------------------------------------------ */
250
+
251
+ /**
252
+ * Install the kit scaffold into a project + user tier.
253
+ * Idempotent. Never overwrites existing target files. Refreshes the
254
+ * managed .gitignore block in place when re-run.
255
+ */
256
+ export async function install(options = {}) {
257
+ const projectRoot = options.projectRoot
258
+ ? resolve(options.projectRoot)
259
+ : resolve(process.cwd());
260
+ const userTier = options.userTier ? resolve(options.userTier) : resolveUserTier();
261
+ const force = !!options.force;
262
+ const version = options.version || getKitVersion();
263
+
264
+ const templateDir = resolveTemplateDir();
265
+
266
+ const created = [];
267
+ const skipped = [];
268
+ const errors = [];
269
+
270
+ installTier(join(templateDir, 'project'), join(projectRoot, 'context'), { created, skipped, errors });
271
+ installTier(join(templateDir, 'local'), join(projectRoot, 'context.local'), { created, skipped, errors });
272
+ installTier(join(templateDir, 'user'), userTier, { created, skipped, errors });
273
+
274
+ const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir));
275
+
276
+ // CLAUDE.md loader block — Task 4. Read the block content from the kit's
277
+ // template/ and inject (or refresh) it inside marker delimiters. Never
278
+ // touches content outside the markers.
279
+ const claudeMdTemplatePath = join(templateDir, 'CLAUDE.md.template');
280
+ let claudeMd = { action: 'skipped', path: join(projectRoot, 'CLAUDE.md') };
281
+ if (existsSync(claudeMdTemplatePath)) {
282
+ const content = readFileSync(claudeMdTemplatePath, 'utf8');
283
+ try {
284
+ claudeMd = injectClaudeMdBlock({ projectRoot, content, version, force });
285
+ } catch (err) {
286
+ errors.push({
287
+ path: join(projectRoot, 'CLAUDE.md'),
288
+ error: err && err.message ? err.message : String(err),
289
+ });
290
+ }
291
+ } else {
292
+ errors.push({
293
+ path: claudeMdTemplatePath,
294
+ error: 'CLAUDE.md.template missing from kit template/',
295
+ });
296
+ }
297
+
298
+ return { projectRoot, userTier, created, skipped, gitignore, claudeMd, errors };
299
+ }
300
+
301
+ /**
302
+ * `cmk init-user-tier` — user-tier-only install. Task 14.
303
+ *
304
+ * Scaffolds the user-tier seeds (USER.md, HABITS.md, LESSONS.md, fragments/)
305
+ * at the resolved user-tier path. Does NOT touch project tier, local tier,
306
+ * .gitignore, or CLAUDE.md. Useful when:
307
+ * - A user wants to set up user-tier independently of any project install
308
+ * - A user wants to refresh user-tier seeds without re-running `cmk install`
309
+ * (which would also re-evaluate project tier + CLAUDE.md block)
310
+ *
311
+ * Path precedence (same as install()): explicit option > $MEMORY_KIT_USER_DIR
312
+ * > ~/.claude-memory-kit/. Re-runs are idempotent — existing files are
313
+ * skipped, not overwritten.
314
+ *
315
+ * Returns {userTier, created, skipped, errors}.
316
+ */
317
+ export function initUserTier(options = {}) {
318
+ const userTier = options.userTier
319
+ ? resolve(options.userTier)
320
+ : resolveUserTier();
321
+ const templateDir = resolveTemplateDir();
322
+ const created = [];
323
+ const skipped = [];
324
+ const errors = [];
325
+ installTier(join(templateDir, 'user'), userTier, { created, skipped, errors });
326
+ return { userTier, created, skipped, errors };
327
+ }
@@ -0,0 +1,326 @@
1
+ // Lazy compression fallback (Task 35, T-030).
2
+ //
3
+ // For environments where cron / launchd / Task Scheduler isn't available
4
+ // (corporate Windows without Task Scheduler access, restricted CI runners,
5
+ // ephemeral dev containers), the kit falls back to lazy-on-read compression
6
+ // triggered by the SessionStart hook (inject-context.mjs).
7
+ //
8
+ // Two public boundaries:
9
+ //
10
+ // detectStaleness({projectRoot, now, dailyTtlMs?, weeklyTtlMs?})
11
+ // → cheap (<5ms) inline check at SessionStart. Returns the
12
+ // work-needed verdict; inject-context.mjs uses it to decide
13
+ // whether to spawn `cmk compress --lazy`.
14
+ //
15
+ // async runLazyCompress({projectRoot, backend, now, cooldownMs?, dailyTtlMs?, weeklyTtlMs?})
16
+ // → the actual work. Composes on dailyDistill (Task 33) or
17
+ // weeklyCurate (Task 34) depending on staleness verdict.
18
+ //
19
+ // Cron-detection sentinel:
20
+ // <projectRoot>/context/.locks/cron-registered — marker file
21
+ // written by registerCron, removed by unregisterCron. When
22
+ // present, detectStaleness returns 'cron-active' so cmk compress
23
+ // --lazy becomes a no-op.
24
+ //
25
+ // Per design §8.2.1 + §8.2.2 + tasks.md 35.
26
+
27
+ import {
28
+ appendFileSync,
29
+ existsSync,
30
+ mkdirSync,
31
+ readdirSync,
32
+ statSync,
33
+ writeFileSync,
34
+ unlinkSync,
35
+ } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { nowIso } from './audit-log.mjs';
38
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
39
+ import {
40
+ DEFAULT_COOLDOWN_MS,
41
+ isCooldownActive,
42
+ } from './cooldown.mjs';
43
+ import { dailyDistill } from './daily-distill.mjs';
44
+ import { weeklyCurate } from './weekly-curate.mjs';
45
+
46
+ const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
47
+ const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
48
+ const SESSIONS_REL = ['context', 'sessions'];
49
+ const LOCKS_REL = ['context', '.locks'];
50
+ const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
51
+ const CRON_SENTINEL_REL = ['context', '.locks', 'cron-registered'];
52
+ const LAZY_LOG_REL = ['context', '.locks', 'lazy-compress.log'];
53
+
54
+ const TODAY_RE = /^today-(\d{4}-\d{2}-\d{2})\.md$/;
55
+
56
+ /**
57
+ * Path helper for the cron-registered sentinel marker file. Public so
58
+ * register-crons.mjs can write/remove it without re-deriving the path.
59
+ */
60
+ export function cronSentinelPath(projectRoot) {
61
+ return join(projectRoot, ...CRON_SENTINEL_REL);
62
+ }
63
+
64
+ /**
65
+ * Write the cron-registered sentinel marker. Called by registerCron
66
+ * after a successful host-scheduler registration.
67
+ */
68
+ export function markCronRegistered({ projectRoot }) {
69
+ if (!projectRoot) return;
70
+ const locksDir = join(projectRoot, ...LOCKS_REL);
71
+ mkdirSync(locksDir, { recursive: true });
72
+ writeFileSync(cronSentinelPath(projectRoot), nowIso() + '\n', 'utf8');
73
+ }
74
+
75
+ /**
76
+ * Remove the cron-registered sentinel marker. Called by unregisterCron.
77
+ * Best-effort — if the marker is missing, that's already the desired state.
78
+ */
79
+ export function unmarkCronRegistered({ projectRoot }) {
80
+ if (!projectRoot) return;
81
+ const path = cronSentinelPath(projectRoot);
82
+ if (existsSync(path)) {
83
+ try {
84
+ unlinkSync(path);
85
+ } catch {
86
+ // best-effort
87
+ }
88
+ }
89
+ }
90
+
91
+ function listTodayFiles(projectRoot) {
92
+ const sessionsDir = join(projectRoot, ...SESSIONS_REL);
93
+ if (!existsSync(sessionsDir)) return [];
94
+ const matches = [];
95
+ for (const name of readdirSync(sessionsDir)) {
96
+ const m = TODAY_RE.exec(name);
97
+ if (!m) continue;
98
+ matches.push({ name, date: m[1], path: join(sessionsDir, name) });
99
+ }
100
+ return matches;
101
+ }
102
+
103
+ function recentMdMtimeMs(projectRoot) {
104
+ const p = join(projectRoot, ...RECENT_MD_REL);
105
+ if (!existsSync(p)) return null;
106
+ try {
107
+ return statSync(p).mtimeMs;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Cheap inline staleness check. Runs in <5ms — one stat + a few existsSync.
115
+ *
116
+ * Verdict semantics:
117
+ * - 'cron-active' : sentinel exists; cron will handle staleness. No-op.
118
+ * - 'no-context-dir': context/sessions/ doesn't exist. No-op (kit not installed).
119
+ * - 'stale-weekly' : ANY today-*.md older than 7d exists. Weekly curate needed.
120
+ * - 'stale-daily' : no OLD today files, but recent.md is missing OR older than dailyTtlMs.
121
+ * - 'fresh' : recent.md exists + younger than dailyTtlMs AND no OLD today files.
122
+ *
123
+ * weekly takes precedence over daily — weekly-curate also rebuilds recent.md
124
+ * (per §8.7.2), so doing weekly when both are stale handles both.
125
+ */
126
+ export function detectStaleness({
127
+ projectRoot,
128
+ now,
129
+ dailyTtlMs = DEFAULT_DAILY_TTL_MS,
130
+ weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
131
+ } = {}) {
132
+ if (!projectRoot) {
133
+ return { action: 'no-context-dir', reason: 'missing-project-root' };
134
+ }
135
+ // Cron sentinel short-circuits everything.
136
+ if (existsSync(cronSentinelPath(projectRoot))) {
137
+ return { action: 'cron-active', reason: 'cron-sentinel-present' };
138
+ }
139
+ const sessionsDir = join(projectRoot, ...SESSIONS_REL);
140
+ if (!existsSync(sessionsDir)) {
141
+ return { action: 'no-context-dir', reason: 'sessions-dir-missing' };
142
+ }
143
+
144
+ const ts = now ?? nowIso();
145
+ const nowMs = new Date(ts).getTime();
146
+ const files = listTodayFiles(projectRoot);
147
+
148
+ // Weekly check: any today-*.md older than weeklyTtlMs by its date stamp
149
+ // (NOT mtime — the file's date is the canonical age signal; mtime can
150
+ // drift if someone touched the file).
151
+ const weeklyCutoffMs = nowMs - weeklyTtlMs;
152
+ const hasOldToday = files.some((f) => {
153
+ const fileMs = new Date(f.date + 'T00:00:00Z').getTime();
154
+ return Number.isFinite(fileMs) && fileMs < weeklyCutoffMs;
155
+ });
156
+ if (hasOldToday) {
157
+ return { action: 'stale-weekly', reason: 'today-file-older-than-7d' };
158
+ }
159
+
160
+ // Task 36 I1 fix: if there are NO today-*.md files at all, the
161
+ // pipeline has nothing to compress — return fresh regardless of
162
+ // recent.md mtime. Previously this check only fired when recent.md
163
+ // was MISSING; for the stale-but-no-input case (e.g., right after
164
+ // weeklyCurate archived every today file), the daily-stale branch
165
+ // would fire and the SessionStart hook would spawn lazy-compress
166
+ // forever (no new today file means no work; dailyDistill would
167
+ // return skipped:no-input but not touch recent.md, so the next
168
+ // SessionStart sees the same stale verdict).
169
+ if (files.length === 0) {
170
+ return { action: 'fresh', reason: 'no-input' };
171
+ }
172
+
173
+ // Daily check: recent.md missing OR older than dailyTtlMs.
174
+ const mtimeMs = recentMdMtimeMs(projectRoot);
175
+ if (mtimeMs === null) {
176
+ return { action: 'stale-daily', reason: 'recent-md-missing' };
177
+ }
178
+ if (nowMs - mtimeMs > dailyTtlMs) {
179
+ return { action: 'stale-daily', reason: 'recent-md-older-than-ttl' };
180
+ }
181
+ return { action: 'fresh', reason: 'within-ttl' };
182
+ }
183
+
184
+ function writeLazyLogEntry({ projectRoot, entry }) {
185
+ const path = join(projectRoot, ...LAZY_LOG_REL);
186
+ mkdirSync(join(projectRoot, ...LOCKS_REL), { recursive: true });
187
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
188
+ return path;
189
+ }
190
+
191
+ /**
192
+ * Run the lazy-compress cycle. Dispatches to dailyDistill or weeklyCurate
193
+ * based on detectStaleness verdict.
194
+ *
195
+ * @returns {Promise<object>}
196
+ */
197
+ export async function runLazyCompress({
198
+ projectRoot,
199
+ backend,
200
+ now,
201
+ cooldownMs = DEFAULT_COOLDOWN_MS,
202
+ dailyTtlMs = DEFAULT_DAILY_TTL_MS,
203
+ weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
204
+ } = {}) {
205
+ const ts = now ?? nowIso();
206
+ const t0 = Date.now();
207
+
208
+ if (!projectRoot) {
209
+ return errorResult({
210
+ category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
211
+ errors: ['projectRoot is required'],
212
+ duration_ms: Date.now() - t0,
213
+ });
214
+ }
215
+ if (!backend || typeof backend.compress !== 'function') {
216
+ return errorResult({
217
+ category: ERROR_CATEGORIES.MISSING_BACKEND,
218
+ errors: ['backend (CompressorBackend) is required'],
219
+ duration_ms: Date.now() - t0,
220
+ });
221
+ }
222
+
223
+ // Cooldown gate up front — composes with shared 120s marker.
224
+ if (isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
225
+ const duration_ms = Date.now() - t0;
226
+ writeLazyLogEntry({
227
+ projectRoot,
228
+ entry: {
229
+ ts,
230
+ scope: 'lazy-compress',
231
+ action: 'skipped',
232
+ reason: 'cooldown',
233
+ // M1 fix: include verdict + delegated_to with null sentinels so
234
+ // every NDJSON entry shares the same schema (downstream `cmk
235
+ // doctor` HC-6 parsing can rely on key presence). The Haiku
236
+ // call was gated, so verdict was never computed and delegation
237
+ // never happened.
238
+ verdict: null,
239
+ delegated_to: null,
240
+ duration_ms,
241
+ },
242
+ });
243
+ return { action: 'skipped', reason: 'cooldown', duration_ms };
244
+ }
245
+
246
+ const verdict = detectStaleness({
247
+ projectRoot,
248
+ now: ts,
249
+ dailyTtlMs,
250
+ weeklyTtlMs,
251
+ });
252
+
253
+ if (verdict.action === 'cron-active') {
254
+ const duration_ms = Date.now() - t0;
255
+ writeLazyLogEntry({
256
+ projectRoot,
257
+ entry: {
258
+ ts,
259
+ scope: 'lazy-compress',
260
+ action: 'skipped',
261
+ reason: 'cron-active',
262
+ duration_ms,
263
+ },
264
+ });
265
+ return { action: 'skipped', reason: 'cron-active', duration_ms };
266
+ }
267
+
268
+ if (verdict.action === 'no-context-dir' || verdict.action === 'fresh') {
269
+ const duration_ms = Date.now() - t0;
270
+ writeLazyLogEntry({
271
+ projectRoot,
272
+ entry: {
273
+ ts,
274
+ scope: 'lazy-compress',
275
+ action: 'skipped',
276
+ reason: verdict.reason,
277
+ verdict: verdict.action,
278
+ duration_ms,
279
+ },
280
+ });
281
+ return { action: 'skipped', reason: verdict.reason, duration_ms };
282
+ }
283
+
284
+ // verdict.action is 'stale-daily' or 'stale-weekly'.
285
+ // Delegate to the appropriate cycle, passing cooldownMs=0 because we
286
+ // already gated above; the inner call shouldn't gate a second time on
287
+ // the same marker (which they would not touch yet).
288
+ let result;
289
+ if (verdict.action === 'stale-weekly') {
290
+ result = await weeklyCurate({
291
+ projectRoot,
292
+ backend,
293
+ now: ts,
294
+ cooldownMs: 0,
295
+ });
296
+ } else {
297
+ result = await dailyDistill({
298
+ projectRoot,
299
+ backend,
300
+ now: ts,
301
+ cooldownMs: 0,
302
+ });
303
+ }
304
+
305
+ const duration_ms = Date.now() - t0;
306
+ writeLazyLogEntry({
307
+ projectRoot,
308
+ entry: {
309
+ ts,
310
+ scope: 'lazy-compress',
311
+ action: result?.action ?? 'unknown',
312
+ verdict: verdict.action,
313
+ delegated_to: verdict.action === 'stale-weekly' ? 'weekly-curate' : 'daily-distill',
314
+ duration_ms,
315
+ success: result?.action !== 'error',
316
+ ...(result?.errorCategory ? { error_category: result.errorCategory } : {}),
317
+ },
318
+ });
319
+ return {
320
+ ...result,
321
+ verdict: verdict.action,
322
+ delegatedTo:
323
+ verdict.action === 'stale-weekly' ? 'weekly-curate' : 'daily-distill',
324
+ duration_ms,
325
+ };
326
+ }