@lh8ppl/claude-memory-kit 0.4.0 → 0.4.2

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.
@@ -0,0 +1,204 @@
1
+ // Compaction-state deep module (Task 167, D-206/D-207).
2
+ //
3
+ // Owns the single question "does memory need compacting right now, and is the
4
+ // scheduled cron alive?" — replacing the old detectStaleness gating that
5
+ // short-circuited to 'cron-active' on the mere EXISTENCE of a cron-registered
6
+ // sentinel (so a registered-but-dead cron disabled the lazy roll and now.md grew
7
+ // unbounded — the v0.4.0 dogfood's 410 KB freeze).
8
+ //
9
+ // Two public methods (the deep interface — Q3):
10
+ //
11
+ // isCompactionNeeded({projectRoot, now, dailyTtlMs?, weeklyTtlMs?})
12
+ // → {verdict, cronStale, heartbeatAge}
13
+ // verdict: 'fresh' | 'stale-now' | 'stale-daily' | 'stale-weekly'
14
+ // | 'cron-active' | 'no-context-dir'
15
+ // cronStale: boolean — a cron IS registered but its heartbeat is stale
16
+ // heartbeatAge: number|null — ms since the last cron run, null if no cron
17
+ //
18
+ // recordCronHeartbeat({projectRoot, now}) — the ONLY writer; the cron bins
19
+ // (cmk-daily-distill / cmk-weekly-curate) call it on each fire so the gate
20
+ // keys off "a run HAPPENED recently" (age), not "a scheduler is registered"
21
+ // (existence). The anacron model — see docs/research/2026-06-25-cron-liveness.
22
+ //
23
+ // Marker-vs-derive = HYBRID (Q2; the GNU make "Empty Target Files" rule): the
24
+ // now/daily/weekly verdicts are DERIVED from the artifacts the work already
25
+ // rewrites (now.md content, recent.md mtime, today-*.md dates — no new marker,
26
+ // ADR-0002); only cron-liveness gets a stamp, because no artifact expresses "is
27
+ // the background scheduler alive".
28
+
29
+ import {
30
+ existsSync,
31
+ mkdirSync,
32
+ readdirSync,
33
+ readFileSync,
34
+ statSync,
35
+ utimesSync,
36
+ writeFileSync,
37
+ } from 'node:fs';
38
+ import { dirname, join } from 'node:path';
39
+
40
+ export const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
41
+ export const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
42
+
43
+ // The cron heartbeat is considered alive within ~2× the daily cron interval
44
+ // (the anacron model: a registered cron that hasn't run in 2 days is dead — the
45
+ // machine was asleep, the registration silently failed, etc.). 2× the 24h daily
46
+ // cadence gives one full grace period before the lazy roll takes over.
47
+ export const DEFAULT_HEARTBEAT_TTL_MS = 2 * DEFAULT_DAILY_TTL_MS; // 48 hours
48
+
49
+ const SESSIONS_REL = ['context', 'sessions'];
50
+ const NOW_MD_REL = ['context', 'sessions', 'now.md'];
51
+ const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
52
+ const HEARTBEAT_REL = ['context', '.locks', 'cron-heartbeat'];
53
+
54
+ const TODAY_RE = /^today-(\d{4}-\d{2}-\d{2})\.md$/;
55
+
56
+ /**
57
+ * Path to the cron-heartbeat stamp. Public so register-crons.mjs + the cron bins
58
+ * can write it without re-deriving the path.
59
+ */
60
+ export function cronHeartbeatPath(projectRoot) {
61
+ return join(projectRoot, ...HEARTBEAT_REL);
62
+ }
63
+
64
+ /**
65
+ * Record that the scheduled cron actually RAN (the anacron stamp). The ONLY
66
+ * writer of the heartbeat — called by the cron bins on each fire (success OR
67
+ * benign no-op; "ran and did nothing" still proves the cron is alive). Best-effort
68
+ * + atomic-enough (mkdir then touch); mtime is the load-bearing signal.
69
+ */
70
+ export function recordCronHeartbeat({ projectRoot, now }) {
71
+ if (!projectRoot) return;
72
+ const marker = cronHeartbeatPath(projectRoot);
73
+ mkdirSync(dirname(marker), { recursive: true });
74
+ if (!existsSync(marker)) {
75
+ writeFileSync(marker, '', 'utf8');
76
+ }
77
+ const ts = new Date(now ?? Date.now());
78
+ try {
79
+ utimesSync(marker, ts, ts);
80
+ } catch {
81
+ // utimes can fail on exotic filesystems; existence + a write timestamp are
82
+ // the load-bearing signal, and writeFileSync already stamped it on create.
83
+ }
84
+ }
85
+
86
+ /** ms since the cron last ran, or null if no cron heartbeat exists. */
87
+ function heartbeatAgeMs(projectRoot, nowMs) {
88
+ const marker = cronHeartbeatPath(projectRoot);
89
+ if (!existsSync(marker)) return null;
90
+ try {
91
+ return nowMs - statSync(marker).mtimeMs;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ function nowMdHasContent(projectRoot) {
98
+ const p = join(projectRoot, ...NOW_MD_REL);
99
+ if (!existsSync(p)) return false;
100
+ try {
101
+ return readFileSync(p, 'utf8').trim() !== '';
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function recentMdMtimeMs(projectRoot) {
108
+ const p = join(projectRoot, ...RECENT_MD_REL);
109
+ if (!existsSync(p)) return null;
110
+ try {
111
+ return statSync(p).mtimeMs;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function listTodayFiles(projectRoot) {
118
+ const sessionsDir = join(projectRoot, ...SESSIONS_REL);
119
+ if (!existsSync(sessionsDir)) return [];
120
+ const matches = [];
121
+ for (const name of readdirSync(sessionsDir)) {
122
+ const m = TODAY_RE.exec(name);
123
+ if (!m) continue;
124
+ matches.push({ name, date: m[1], path: join(sessionsDir, name) });
125
+ }
126
+ return matches;
127
+ }
128
+
129
+ /**
130
+ * The deep read: does memory need compacting, and is the cron alive?
131
+ *
132
+ * Verdict precedence (Q2): no-context-dir > stale-now > cron-active(fresh) >
133
+ * stale-weekly > stale-daily > fresh. Note 'cron-active' NO LONGER short-circuits
134
+ * above the bloat check — a stale-now (un-rolled prior-session content) ALWAYS
135
+ * wins, because correctness > deferring to a possibly-dead cron ("we're in the
136
+ * memory business", Q4). Only a FRESH cron defers the derived daily/weekly work.
137
+ */
138
+ export function isCompactionNeeded({
139
+ projectRoot,
140
+ now,
141
+ dailyTtlMs = DEFAULT_DAILY_TTL_MS,
142
+ weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
143
+ heartbeatTtlMs = DEFAULT_HEARTBEAT_TTL_MS,
144
+ } = {}) {
145
+ const nowMs = new Date(now ?? Date.now()).getTime();
146
+
147
+ if (!projectRoot) {
148
+ return { verdict: 'no-context-dir', reason: 'missing-project-root', cronStale: false, heartbeatAge: null };
149
+ }
150
+
151
+ // Cron liveness — by AGE, never existence (the 167.A fix). A registered cron
152
+ // whose heartbeat is older than the TTL is DEAD; treat it as no cron so the
153
+ // lazy roll takes over.
154
+ const hbAge = heartbeatAgeMs(projectRoot, nowMs);
155
+ const cronRegistered = hbAge !== null;
156
+ const cronAlive = cronRegistered && hbAge < heartbeatTtlMs;
157
+ const cronStale = cronRegistered && !cronAlive;
158
+
159
+ const base = { cronStale, heartbeatAge: hbAge };
160
+
161
+ const sessionsDir = join(projectRoot, ...SESSIONS_REL);
162
+ if (!existsSync(sessionsDir)) {
163
+ return { verdict: 'no-context-dir', reason: 'sessions-dir-missing', ...base };
164
+ }
165
+
166
+ // stale-now wins over cron-active (Q4): un-rolled now.md content must drain
167
+ // THIS session regardless of whether a (possibly dead, possibly alive) cron is
168
+ // registered — the correctness-over-deferral rule.
169
+ if (nowMdHasContent(projectRoot)) {
170
+ return { verdict: 'stale-now', reason: 'now-md-has-prior-session-content', ...base };
171
+ }
172
+
173
+ // A LIVE cron owns the remaining (daily/weekly) levels — defer to it.
174
+ if (cronAlive) {
175
+ return { verdict: 'cron-active', reason: 'cron-heartbeat-fresh', ...base };
176
+ }
177
+
178
+ const files = listTodayFiles(projectRoot);
179
+
180
+ // Weekly: any today-*.md older than weeklyTtlMs by its date stamp.
181
+ const weeklyCutoffMs = nowMs - weeklyTtlMs;
182
+ const hasOldToday = files.some((f) => {
183
+ const fileMs = new Date(f.date + 'T00:00:00Z').getTime();
184
+ return Number.isFinite(fileMs) && fileMs < weeklyCutoffMs;
185
+ });
186
+ if (hasOldToday) {
187
+ return { verdict: 'stale-weekly', reason: 'today-file-older-than-7d', ...base };
188
+ }
189
+
190
+ // No today files at all → nothing to compress → fresh (Task 36 I1).
191
+ if (files.length === 0) {
192
+ return { verdict: 'fresh', reason: 'no-input', ...base };
193
+ }
194
+
195
+ // Daily: recent.md missing OR older than dailyTtlMs.
196
+ const mtimeMs = recentMdMtimeMs(projectRoot);
197
+ if (mtimeMs === null) {
198
+ return { verdict: 'stale-daily', reason: 'recent-md-missing', ...base };
199
+ }
200
+ if (nowMs - mtimeMs > dailyTtlMs) {
201
+ return { verdict: 'stale-daily', reason: 'recent-md-older-than-ttl', ...base };
202
+ }
203
+ return { verdict: 'fresh', reason: 'within-ttl', ...base };
204
+ }
@@ -96,7 +96,21 @@ function coerce(raw) {
96
96
  return raw;
97
97
  }
98
98
 
99
- function setDeep(obj, dottedKey, value) {
99
+ // Exported for a direct unit test: this guard holds a security invariant
100
+ // (prototype-pollution resistance) and is analyzed by CodeQL in isolation, so
101
+ // it's tested at its own boundary, not only through configSet.
102
+ export function setDeep(obj, dottedKey, value) {
103
+ // Defense-in-depth: refuse prototype-polluting segments INSIDE the walker
104
+ // itself, not only at the public entry points (configGet/Set/ShowOrigin all
105
+ // pre-check via hasForbiddenSegment). A self-guarding utility stays safe even
106
+ // if a future caller forgets the guard — and it closes the CodeQL
107
+ // js/prototype-pollution-utility finding. Reuses the same helper as the entry
108
+ // points so the forbidden-segment set can't drift.
109
+ if (hasForbiddenSegment(dottedKey)) {
110
+ throw new Error(
111
+ `setDeep: forbidden key segment (${[...FORBIDDEN_KEYS].join('/')}) — prototype-pollution guard`,
112
+ );
113
+ }
100
114
  const parts = dottedKey.split('.');
101
115
  let cur = obj;
102
116
  for (let i = 0; i < parts.length - 1; i++) {
package/src/doctor.mjs CHANGED
@@ -43,6 +43,7 @@ import { basename, join } from 'node:path';
43
43
  import { nowIso } from './audit-log.mjs';
44
44
  import { detectStaleLocks } from './lock-discipline.mjs';
45
45
  import { cronSentinelPath } from './lazy-compress.mjs';
46
+ import { isCompactionNeeded } from './compaction-state.mjs';
46
47
  import { getNativeAutoMemoryState } from './native-memory.mjs';
47
48
  import { checkKitBinding, checkEmbedderBinding } from './native-binding.mjs';
48
49
  import { resolveDefaultSearchMode } from './semantic-backend.mjs';
@@ -640,6 +641,45 @@ function hc9VersionDrift({ projectRoot, kitVersion }) {
640
641
  return checkVersionDrift({ claudeMdText, kitVersion });
641
642
  }
642
643
 
644
+ // --- HC-10: Scheduled compaction liveness (Task 167 / D-207) ----------
645
+ // INFORMATIONAL — memory self-heals automatically every session (the lazy roll
646
+ // is the floor, 167.A/D), so a dead cron is a degraded OPTIMIZATION, not data
647
+ // loss. NEVER prescribes a manual heal (no recoveryCommand). A DEV/diagnostic
648
+ // aid (it surfaced THIS bug class) + a heads-up for a power user. SKIPs when no
649
+ // cron is registered (the default) — the lazy roll covers compaction there.
650
+ function hc10CompactionLiveness({ projectRoot, now }) {
651
+ const v = isCompactionNeeded({ projectRoot, now });
652
+ // No cron registered (heartbeatAge null) → nothing to check; the lazy roll owns
653
+ // compaction. SKIP (consistent with HC-5's optional-cron posture).
654
+ if (v.heartbeatAge === null) {
655
+ return {
656
+ id: 'HC-10',
657
+ name: 'Scheduled compaction is alive',
658
+ status: 'skip',
659
+ message: 'no scheduled cron registered (optional) — memory self-heals each session via the lazy roll.',
660
+ };
661
+ }
662
+ if (v.cronStale) {
663
+ const days = Math.floor(v.heartbeatAge / (24 * 60 * 60 * 1000));
664
+ return {
665
+ id: 'HC-10',
666
+ name: 'Scheduled compaction is alive',
667
+ status: 'fail',
668
+ // Informational framing — NO recoveryCommand. The kit self-heals; this is a
669
+ // heads-up that the OPTIONAL nightly schedule isn't firing (asleep at 23:00,
670
+ // a registration that didn't take catch-up). Re-running register-crons is a
671
+ // user choice, not a required repair.
672
+ message: `your scheduled compaction looks dead (last ran ~${days}d ago) — memory still self-heals automatically each session, so no action is needed; run \`cmk register-crons\` only if you want the nightly schedule back.`,
673
+ };
674
+ }
675
+ return {
676
+ id: 'HC-10',
677
+ name: 'Scheduled compaction is alive',
678
+ status: 'pass',
679
+ message: 'scheduled compaction heartbeat is fresh',
680
+ };
681
+ }
682
+
643
683
  export async function runDoctor({
644
684
  projectRoot,
645
685
  userDir,
@@ -672,10 +712,11 @@ export async function runDoctor({
672
712
  const c8 = await hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBindingProbe });
673
713
  // HC-9: kitVersion injectable for tests; defaults to the installed binary's version.
674
714
  const c9 = hc9VersionDrift({ projectRoot, kitVersion: kitVersion ?? getKitVersion() });
715
+ const c10 = hc10CompactionLiveness({ projectRoot, now: ts });
675
716
 
676
717
  return {
677
718
  action: 'completed',
678
- checks: [c1, c2, c3, c4, c5, c6, c7, c8, c9],
719
+ checks: [c1, c2, c3, c4, c5, c6, c7, c8, c9, c10],
679
720
  duration_ms: Date.now() - t0,
680
721
  };
681
722
  }
@@ -34,7 +34,7 @@ import { spawn } from 'node:child_process';
34
34
  import { join, dirname } from 'node:path';
35
35
  import { fileURLToPath } from 'node:url';
36
36
  import { homedir } from 'node:os';
37
- import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
37
+ import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN, discoverRootUpward } from './tier-paths.mjs';
38
38
  import { nowIso } from './audit-log.mjs';
39
39
  import { detectStaleness, isJournalStale } from './lazy-compress.mjs';
40
40
  import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
@@ -171,21 +171,14 @@ function latestDaySession(sessionsDir) {
171
171
  // kit's project-tier root convention is `<repo>/context/`; the walk-up
172
172
  // matches `git rev-parse --show-toplevel`'s semantics for nested invocations
173
173
  // (a hook may fire while Claude Code's cwd is in a sub-package).
174
+ // Task 168: discovery walks up to the nearest project root, recognizing EITHER
175
+ // tier directory — `context/` (committed) OR `context.local/` (gitignored local)
176
+ // — so a local-only project resolves correctly, and STOPPING at the home dir so a
177
+ // stray `~/context/` can't hijack discovery from an unrelated subdir. Shares the
178
+ // single `discoverRootUpward` implementation with resolveMcpProjectRoot (one
179
+ // home-boundary + canonicalize algorithm, no drift across the two walkers).
174
180
  function discoverProjectRoot(cwd) {
175
- let dir = cwd;
176
- // Defensive bound: walk no more than 64 ancestors.
177
- for (let i = 0; i < 64; i++) {
178
- if (existsSync(join(dir, 'context'))) return dir;
179
- const parent = join(dir, '..');
180
- const norm = statSync(parent).isDirectory() ? parent : null;
181
- if (!norm || norm === dir) break;
182
- // Stop at the filesystem root.
183
- if (/^[A-Za-z]:\\?$|^\/$/.test(dir)) break;
184
- dir = parent;
185
- }
186
- // Fall back to `cwd` — the per-tier readers will return empty for
187
- // absent dirs, so this stays safe.
188
- return cwd;
181
+ return discoverRootUpward(cwd, ['context', 'context.local']);
189
182
  }
190
183
 
191
184
  function tierDirExists(tier, tierRoot) {
package/src/install.mjs CHANGED
@@ -494,7 +494,12 @@ export async function install(options = {}) {
494
494
  // the pin-off; checked first below).
495
495
  let semantic = { action: 'skipped' };
496
496
  if (options.withSemantic) {
497
- semantic = await enableSemantic({ projectRoot, spawnNpm: options.spawnNpm, warm: options.warmEmbedder });
497
+ semantic = await enableSemantic({
498
+ projectRoot,
499
+ spawnNpm: options.spawnNpm,
500
+ warm: options.warmEmbedder,
501
+ probeEmbedder: options.probeEmbedder,
502
+ });
498
503
  if (semantic.action === 'error') errors.push({ path: 'semantic', error: semantic.error });
499
504
  } else if (options.noSemantic) {
500
505
  const r = mergeProjectSettings(projectRoot, { search: { default_mode: 'keyword' } });
@@ -571,15 +576,43 @@ export function buildDefaultNpmRunner({ spawnSyncImpl = spawnSync } = {}) {
571
576
  };
572
577
  }
573
578
 
574
- async function enableSemantic({ projectRoot, spawnNpm, warm }) {
579
+ async function enableSemantic({ projectRoot, spawnNpm, warm, probeEmbedder }) {
575
580
  // 1. Install the optional embedder globally (it resolves as a sibling of
576
581
  // the globally-installed kit). Injectable for tests.
577
582
  const runNpm = spawnNpm ?? buildDefaultNpmRunner();
578
583
  const npm = runNpm();
579
- if (npm.status !== 0) {
584
+
585
+ // Task 170 (the v0.4.1 cut-gate find): gate on whether the embedder ACTUALLY
586
+ // IMPORTS, NOT on npm's exit code — verify the thing worked, not the command's
587
+ // exit (the D-199 class). Two failure modes the exit code gets wrong, BOTH
588
+ // sides:
589
+ // - npm exits NON-ZERO but the package installed fine: on Windows a benign
590
+ // cleanup-EBUSY (npm failing to unlink a leftover temp DLL still locked by
591
+ // a running process — sharp-win32-x64 / libvips) makes npm exit non-zero
592
+ // AFTER a successful install. Trusting the exit FALSELY reported "NOT
593
+ // enabled" while the embedder was present + importable (the live find).
594
+ // - npm exits ZERO but the import is BROKEN (partial/corrupt native module):
595
+ // trusting the exit would wrongly write a hybrid default with no working
596
+ // embedder — every search would degrade to the fallback warning (the
597
+ // half-state this function exists to avoid).
598
+ // So PROBE the import once, and enable hybrid IFF it imports — regardless of
599
+ // npm's exit. The install must be AUTOMATIC: the user does nothing and never
600
+ // needs a manual `cmk config set` to recover from a benign npm warning.
601
+ const probe = probeEmbedder ?? (async () => {
602
+ const { checkEmbedderBinding } = await import('./native-binding.mjs');
603
+ return checkEmbedderBinding();
604
+ });
605
+ let imported;
606
+ try {
607
+ imported = await probe();
608
+ } catch {
609
+ imported = { ok: false };
610
+ }
611
+ if (!imported?.ok) {
612
+ const detail = npm.status !== 0 ? (npm.error ?? `exit ${npm.status}`) : (imported?.reason ?? 'embedder import failed');
580
613
  return {
581
614
  action: 'error',
582
- error: `npm install -g @huggingface/transformers failed (${npm.error ?? `exit ${npm.status}`}) — semantic recall NOT enabled; keyword search is unaffected`,
615
+ error: `semantic embedder not usable after install (${detail}) — semantic recall NOT enabled; keyword search is unaffected`,
583
616
  };
584
617
  }
585
618
  // 2. Flip the project default to hybrid ONLY after the dependency landed
@@ -29,9 +29,7 @@ import {
29
29
  existsSync,
30
30
  mkdirSync,
31
31
  readdirSync,
32
- readFileSync,
33
32
  statSync,
34
- writeFileSync,
35
33
  unlinkSync,
36
34
  } from 'node:fs';
37
35
  import { join } from 'node:path';
@@ -46,44 +44,58 @@ import { weeklyCurate } from './weekly-curate.mjs';
46
44
  import { compressSession } from './compress-session.mjs';
47
45
  import { CEILING_FREE_TIMEOUT_MS, CEILING_FREE_BACKOFF_MS } from './compress-retry.mjs';
48
46
  import { syncDecisionsJournal } from './decisions-journal.mjs';
47
+ import {
48
+ isCompactionNeeded,
49
+ recordCronHeartbeat,
50
+ cronHeartbeatPath,
51
+ } from './compaction-state.mjs';
49
52
 
50
53
  const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
51
54
  const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
52
- const SESSIONS_REL = ['context', 'sessions'];
53
55
  const LOCKS_REL = ['context', '.locks'];
54
- const NOW_MD_REL = ['context', 'sessions', 'now.md'];
55
- const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
56
- const CRON_SENTINEL_REL = ['context', '.locks', 'cron-registered'];
57
56
  const LAZY_LOG_REL = ['context', '.locks', 'lazy-compress.log'];
58
57
 
59
- const TODAY_RE = /^today-(\d{4}-\d{2}-\d{2})\.md$/;
58
+ // Task 167 (D-206/D-207): the cron-liveness gate moved from a presence-only
59
+ // `cron-registered` sentinel to an anacron-style `cron-heartbeat` (in
60
+ // compaction-state.mjs), gated on AGE not existence — a registered-but-dead cron
61
+ // no longer disables the lazy roll. These three exports are kept as thin
62
+ // back-compat shims so existing callers (register-crons / doctor / subcommands /
63
+ // tests) keep working; they now operate on the heartbeat.
64
+ //
65
+ // **Decision-trail (the retired sentinel):** the old `cron-registered` marker
66
+ // recorded "a cron was REGISTERED" (written once at register-crons time, never
67
+ // updated). detectStaleness short-circuited to 'cron-active' on its mere
68
+ // existence — so a cron that never actually fired (laptop asleep at 23:00)
69
+ // disabled the lazy fallback forever and now.md grew to 410 KB (the v0.4.0
70
+ // dogfood). The heartbeat records "a run HAPPENED" and is re-stamped each fire.
60
71
 
61
72
  /**
62
- * Path helper for the cron-registered sentinel marker file. Public so
63
- * register-crons.mjs can write/remove it without re-deriving the path.
73
+ * Back-compat alias for the cron-liveness marker path. Now points at the
74
+ * `cron-heartbeat` stamp (was `cron-registered`). Retained for callers that still
75
+ * import it; prefer `cronHeartbeatPath` from compaction-state.mjs in new code.
64
76
  */
65
77
  export function cronSentinelPath(projectRoot) {
66
- return join(projectRoot, ...CRON_SENTINEL_REL);
78
+ return cronHeartbeatPath(projectRoot);
67
79
  }
68
80
 
69
81
  /**
70
- * Write the cron-registered sentinel marker. Called by registerCron
71
- * after a successful host-scheduler registration.
82
+ * Record that the cron is alive. Now writes the anacron-style heartbeat (was: a
83
+ * one-time registration sentinel). `register-crons` calls it on registration so a
84
+ * just-registered cron reads alive until its first real run re-stamps it; the
85
+ * cron BINS also call `recordCronHeartbeat` on each fire (the durable liveness
86
+ * signal). Both go to the same stamp.
72
87
  */
73
88
  export function markCronRegistered({ projectRoot }) {
74
- if (!projectRoot) return;
75
- const locksDir = join(projectRoot, ...LOCKS_REL);
76
- mkdirSync(locksDir, { recursive: true });
77
- writeFileSync(cronSentinelPath(projectRoot), nowIso() + '\n', 'utf8');
89
+ recordCronHeartbeat({ projectRoot });
78
90
  }
79
91
 
80
92
  /**
81
- * Remove the cron-registered sentinel marker. Called by unregisterCron.
82
- * Best-effort if the marker is missing, that's already the desired state.
93
+ * Remove the cron-liveness heartbeat (on unregister). Best-effort a missing
94
+ * stamp is already the desired "no live cron" state.
83
95
  */
84
96
  export function unmarkCronRegistered({ projectRoot }) {
85
97
  if (!projectRoot) return;
86
- const path = cronSentinelPath(projectRoot);
98
+ const path = cronHeartbeatPath(projectRoot);
87
99
  if (existsSync(path)) {
88
100
  try {
89
101
  unlinkSync(path);
@@ -93,18 +105,6 @@ export function unmarkCronRegistered({ projectRoot }) {
93
105
  }
94
106
  }
95
107
 
96
- function listTodayFiles(projectRoot) {
97
- const sessionsDir = join(projectRoot, ...SESSIONS_REL);
98
- if (!existsSync(sessionsDir)) return [];
99
- const matches = [];
100
- for (const name of readdirSync(sessionsDir)) {
101
- const m = TODAY_RE.exec(name);
102
- if (!m) continue;
103
- matches.push({ name, date: m[1], path: join(sessionsDir, name) });
104
- }
105
- return matches;
106
- }
107
-
108
108
  // Task 105 (D-75): does now.md carry prior-session content? The now→today
109
109
  // roll (compressSession) fires only at SessionEnd, and Claude Code fires
110
110
  // SessionEnd ONLY on a clean window-close — so a never-cleanly-closed session
@@ -114,25 +114,10 @@ function listTodayFiles(projectRoot) {
114
114
  // capture-turn writes haven't fired yet), so non-empty ⇒ stale. Emptiness must
115
115
  // match compressSession's own `buffer.trim() === ''` check so the spawn verdict
116
116
  // and the actual roll agree (else we'd spawn for a roll that immediately skips).
117
- function nowMdHasContent(projectRoot) {
118
- const p = join(projectRoot, ...NOW_MD_REL);
119
- if (!existsSync(p)) return false;
120
- try {
121
- return readFileSync(p, 'utf8').trim() !== '';
122
- } catch {
123
- return false;
124
- }
125
- }
126
-
127
- function recentMdMtimeMs(projectRoot) {
128
- const p = join(projectRoot, ...RECENT_MD_REL);
129
- if (!existsSync(p)) return null;
130
- try {
131
- return statSync(p).mtimeMs;
132
- } catch {
133
- return null;
134
- }
135
- }
117
+ // Task 167 (D-207): nowMdHasContent / recentMdMtimeMs / listTodayFiles + the
118
+ // now/daily/weekly derive logic moved to compaction-state.mjs (the deep module
119
+ // that owns the verdict). detectStaleness now delegates there; only isJournalStale
120
+ // (an INDEPENDENT signal, not part of the compaction verdict) stays here.
136
121
 
137
122
  const MEMORY_REL = ['context', 'memory'];
138
123
  const DECISIONS_MD_REL = ['context', 'DECISIONS.md'];
@@ -210,66 +195,14 @@ export function detectStaleness({
210
195
  dailyTtlMs = DEFAULT_DAILY_TTL_MS,
211
196
  weeklyTtlMs = DEFAULT_WEEKLY_TTL_MS,
212
197
  } = {}) {
213
- if (!projectRoot) {
214
- return { action: 'no-context-dir', reason: 'missing-project-root' };
215
- }
216
- // Cron sentinel short-circuits everything.
217
- if (existsSync(cronSentinelPath(projectRoot))) {
218
- return { action: 'cron-active', reason: 'cron-sentinel-present' };
219
- }
220
- const sessionsDir = join(projectRoot, ...SESSIONS_REL);
221
- if (!existsSync(sessionsDir)) {
222
- return { action: 'no-context-dir', reason: 'sessions-dir-missing' };
223
- }
224
-
225
- // Task 105 (D-75): a non-empty now.md is the now→today roll the SessionEnd
226
- // hook would have done. It takes PRECEDENCE over daily/weekly because it's
227
- // the FIRST pipeline level (now → today → recent → archive) — roll it this
228
- // SessionStart; the today→recent + weekly levels cascade on subsequent
229
- // SessionStarts once now.md is drained. (cron-active above still wins — a
230
- // registered cron owns the whole pipeline.)
231
- if (nowMdHasContent(projectRoot)) {
232
- return { action: 'stale-now', reason: 'now-md-has-prior-session-content' };
233
- }
234
-
235
- const ts = now ?? nowIso();
236
- const nowMs = new Date(ts).getTime();
237
- const files = listTodayFiles(projectRoot);
238
-
239
- // Weekly check: any today-*.md older than weeklyTtlMs by its date stamp
240
- // (NOT mtime — the file's date is the canonical age signal; mtime can
241
- // drift if someone touched the file).
242
- const weeklyCutoffMs = nowMs - weeklyTtlMs;
243
- const hasOldToday = files.some((f) => {
244
- const fileMs = new Date(f.date + 'T00:00:00Z').getTime();
245
- return Number.isFinite(fileMs) && fileMs < weeklyCutoffMs;
246
- });
247
- if (hasOldToday) {
248
- return { action: 'stale-weekly', reason: 'today-file-older-than-7d' };
249
- }
250
-
251
- // Task 36 I1 fix: if there are NO today-*.md files at all, the
252
- // pipeline has nothing to compress — return fresh regardless of
253
- // recent.md mtime. Previously this check only fired when recent.md
254
- // was MISSING; for the stale-but-no-input case (e.g., right after
255
- // weeklyCurate archived every today file), the daily-stale branch
256
- // would fire and the SessionStart hook would spawn lazy-compress
257
- // forever (no new today file means no work; dailyDistill would
258
- // return skipped:no-input but not touch recent.md, so the next
259
- // SessionStart sees the same stale verdict).
260
- if (files.length === 0) {
261
- return { action: 'fresh', reason: 'no-input' };
262
- }
263
-
264
- // Daily check: recent.md missing OR older than dailyTtlMs.
265
- const mtimeMs = recentMdMtimeMs(projectRoot);
266
- if (mtimeMs === null) {
267
- return { action: 'stale-daily', reason: 'recent-md-missing' };
268
- }
269
- if (nowMs - mtimeMs > dailyTtlMs) {
270
- return { action: 'stale-daily', reason: 'recent-md-older-than-ttl' };
271
- }
272
- return { action: 'fresh', reason: 'within-ttl' };
198
+ // Task 167 (D-207): delegate to the compaction-state deep module. This is a
199
+ // thin back-compat adapter it maps the rich `{verdict, cronStale,
200
+ // heartbeatAge}` return onto the legacy `{action, reason}` shape callers
201
+ // (inject-context, runLazyCompress) already consume. The KEY behavior change
202
+ // vs the old body: `cron-active` is now gated on heartbeat FRESHNESS (age), not
203
+ // sentinel existence, so a dead cron falls through to the stale verdicts.
204
+ const r = isCompactionNeeded({ projectRoot, now, dailyTtlMs, weeklyTtlMs });
205
+ return { action: r.verdict, reason: r.reason, cronStale: r.cronStale, heartbeatAge: r.heartbeatAge };
273
206
  }
274
207
 
275
208
  function writeLazyLogEntry({ projectRoot, entry }) {
@@ -303,6 +303,37 @@ export function registerCron(opts = {}) {
303
303
  // schtasks path + the verbatim argv). Production never passes it.
304
304
  const spawn = opts.spawn ?? spawnSync;
305
305
  const r = spawn(schtasksExe, argv, { encoding: 'utf8', windowsHide: true, timeout: 10_000 });
306
+
307
+ // Task 167.E (D-207): set StartWhenAvailable so a missed nightly run (laptop
308
+ // asleep at 23:00) runs on wake instead of being silently dropped. schtasks
309
+ // /Create has NO CLI flag for this (verified — not in the help); it's settable
310
+ // only via XML or PowerShell. We use a follow-up PowerShell Set-ScheduledTask,
311
+ // BEST-EFFORT: a failure here never fails registration — the lazy roll
312
+ // (167.A/D) is the guarantee; this is a catch-up OPTIMIZATION. NB: this only
313
+ // covers a missed run while the machine is OFF/asleep; all OS catch-up
314
+ // mechanisms COALESCE multiple missed periods into one run (research note).
315
+ if (r.status === 0) {
316
+ const psExe = join(
317
+ process.env.SystemRoot || process.env.windir || 'C:\\Windows',
318
+ 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe',
319
+ );
320
+ const psScript =
321
+ `try { Set-ScheduledTask -TaskName '${entryName}' ` +
322
+ `-Settings (New-ScheduledTaskSettingsSet -StartWhenAvailable) ` +
323
+ `-ErrorAction Stop | Out-Null } catch { exit 1 }`;
324
+ try {
325
+ spawn(psExe, ['-NoProfile', '-NonInteractive', '-Command', psScript], {
326
+ encoding: 'utf8',
327
+ windowsHide: true,
328
+ timeout: 10_000,
329
+ });
330
+ // We intentionally ignore the PS exit status — registration already
331
+ // succeeded; catch-up is best-effort.
332
+ } catch {
333
+ // never let the catch-up call abort a successful registration
334
+ }
335
+ }
336
+
306
337
  return {
307
338
  action: r.status === 0 ? 'registered' : 'error',
308
339
  platform,