@lh8ppl/claude-memory-kit 0.3.5 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +137 -50
  2. package/bin/cmk-approve-permission.mjs +62 -0
  3. package/bin/cmk-daily-distill.mjs +14 -0
  4. package/bin/cmk-guard-memory.mjs +57 -0
  5. package/bin/cmk-inject-context.mjs +12 -0
  6. package/bin/cmk-weekly-curate.mjs +12 -0
  7. package/package.json +4 -2
  8. package/src/agent-profile.mjs +115 -0
  9. package/src/agent-profiles.mjs +118 -0
  10. package/src/approve-permission.mjs +92 -0
  11. package/src/auto-extract.mjs +17 -10
  12. package/src/auto-persona.mjs +11 -4
  13. package/src/compaction-state.mjs +204 -0
  14. package/src/compress-session.mjs +13 -1
  15. package/src/config-core.mjs +7 -9
  16. package/src/decisions-journal.mjs +71 -3
  17. package/src/doctor.mjs +128 -5
  18. package/src/guard-memory.mjs +151 -0
  19. package/src/import-anthropic-memory.mjs +15 -1
  20. package/src/inject-context.mjs +42 -18
  21. package/src/install-agent.mjs +220 -0
  22. package/src/install-kiro.mjs +287 -0
  23. package/src/install.mjs +53 -7
  24. package/src/kiro-cli-agent.mjs +270 -0
  25. package/src/kiro-constants.mjs +19 -0
  26. package/src/kiro-hook-bin.mjs +105 -0
  27. package/src/kiro-hook-command.mjs +67 -0
  28. package/src/kiro-hook-dispatch.mjs +115 -0
  29. package/src/kiro-ide-hooks.mjs +219 -0
  30. package/src/kiro-permissions.mjs +175 -0
  31. package/src/kiro-skills.mjs +96 -0
  32. package/src/kiro-transcript.mjs +366 -0
  33. package/src/kiro-trusted-commands.mjs +130 -0
  34. package/src/lazy-compress.mjs +43 -110
  35. package/src/managed-block.mjs +138 -0
  36. package/src/memory-write.mjs +23 -8
  37. package/src/mutate-agent-config.mjs +243 -0
  38. package/src/read-json.mjs +43 -0
  39. package/src/register-crons.mjs +31 -0
  40. package/src/reindex.mjs +15 -2
  41. package/src/repair.mjs +39 -3
  42. package/src/result-shapes.mjs +8 -0
  43. package/src/review-queue.mjs +3 -0
  44. package/src/scratchpad.mjs +12 -2
  45. package/src/search.mjs +12 -5
  46. package/src/semantic-backend.mjs +7 -9
  47. package/src/settings-hooks.mjs +70 -3
  48. package/src/subcommands.mjs +360 -27
  49. package/src/tier-paths.mjs +82 -1
  50. package/src/weekly-curate.mjs +6 -2
  51. package/template/.claude/skills/memory-search/SKILL.md +14 -1
  52. package/template/.claude/skills/memory-write/SKILL.md +37 -1
  53. package/template/project/memory/INDEX.md.template +1 -1
@@ -0,0 +1,118 @@
1
+ // agent-profiles.mjs — the per-agent profile REGISTRY (Task 50.C/50.E).
2
+ //
3
+ // Each agent is a pure-DATA profile built via defineAgentProfile (the D-180
4
+ // "data, not classes" seam). The kit's core (store / compression / search / CLI
5
+ // / MCP server) is identical across agents; only these declarations differ.
6
+ //
7
+ // Scope discipline (D-180): a plain object keyed by name, NOT a registry
8
+ // framework — single-digit agent count; opencode's data-row registry pays off
9
+ // at N≈75, premature here. Adding an agent in a later version = one more entry.
10
+ //
11
+ // Public surface:
12
+ // AGENT_PROFILES — frozen map { name → frozen descriptor }
13
+ // getAgentProfile(name) → descriptor | undefined
14
+ // listAgentProfiles() → descriptor[]
15
+
16
+ import { defineAgentProfile } from './agent-profile.mjs';
17
+
18
+ // ── Claude Code ──────────────────────────────────────────────────────────────
19
+ // The reference profile. Declares the SAME legs install.mjs wires today (the
20
+ // KIT_HOOKS_BLOCK in settings-hooks.mjs + the `cmk` MCP server in .mcp.json),
21
+ // expressed as data. install.mjs keeps using its existing Claude-Code path for
22
+ // v0.4.0 (regression-proof); this profile is the canonical declaration the
23
+ // routing (50.F) dispatches on and the parity validator (50.D) checks.
24
+ const claudeCode = defineAgentProfile({
25
+ name: 'claude-code',
26
+ displayName: 'Claude Code',
27
+ integrationType: 'native-hooks-mcp',
28
+ detect: { command: 'claude' },
29
+ // Claude Code's instruction surface is the managed block in CLAUDE.md.
30
+ instructionFile: 'CLAUDE.md',
31
+ mcp: { path: '.mcp.json', serversKey: 'mcpServers' },
32
+ hooks: {
33
+ mechanism: 'settings-json', // <projectRoot>/.claude/settings.json hooks[] entries
34
+ path: '.claude/settings.json',
35
+ // abstract lifecycle event → Claude Code's event name (here they coincide).
36
+ eventMap: {
37
+ sessionStart: 'SessionStart',
38
+ promptSubmit: 'UserPromptSubmit',
39
+ postEdit: 'PostToolUse',
40
+ turnEnd: 'Stop',
41
+ sessionEnd: 'SessionEnd',
42
+ },
43
+ },
44
+ // Claude Code transcripts: ~/.claude/projects/<slug>/<session>.jsonl (JSONL).
45
+ transcript: { dir: '~/.claude/projects', workspaceKey: 'slug', parse: 'jsonl' },
46
+ });
47
+
48
+ // ── Kiro (Task 50.E) ─────────────────────────────────────────────────────────
49
+ // Primary-verified against kiro.dev + a real install (D-180). VS Code fork.
50
+ // Kiro wires BOTH hook surfaces (Task 50.N — full Claude-Code parity):
51
+ // • CLI agent-config (.kiro/agents/cmk.json "hooks") — the eventMap below;
52
+ // • IDE Agent-Hooks (.kiro/hooks/*.json v1 + legacy .kiro.hook) — kiro-ide-hooks.mjs.
53
+ // Both drive the SAME `cmk hook <event>` dispatcher → the same inject/capture/
54
+ // observe/guard cores; only the per-surface trigger names differ (CLI camelCase
55
+ // vs IDE v1 PascalCase). All four legs (inject/capture/observe-edit/delete-guard)
56
+ // are wired on both surfaces as of 50.N.1–50.N.3.
57
+ const kiro = defineAgentProfile({
58
+ name: 'kiro',
59
+ displayName: 'Kiro',
60
+ integrationType: 'native-hooks-mcp',
61
+ detect: { homeDir: '.kiro' },
62
+ // Steering file with `inclusion: always` frontmatter (applied at write time).
63
+ instructionFile: '.kiro/steering/claude-memory-kit.md',
64
+ mcp: { path: '.kiro/settings/mcp.json', serversKey: 'mcpServers' },
65
+ hooks: {
66
+ mechanism: 'agent-config-json', // .kiro/agents/<name>.json "hooks" object (CLI)
67
+ path: '.kiro/agents/cmk.json',
68
+ // The CLI agent-config trigger names. The IDE v1 surface uses PascalCase
69
+ // equivalents (UserPromptSubmit/Stop/PostToolUse/PreToolUse) — see
70
+ // kiro-ide-hooks.mjs. postEdit→postToolUse + the delete-guard (preToolUse)
71
+ // are both wired (50.N.2/50.N.3).
72
+ eventMap: {
73
+ sessionStart: 'agentSpawn',
74
+ promptSubmit: 'userPromptSubmit',
75
+ postEdit: 'postToolUse',
76
+ turnEnd: 'stop',
77
+ },
78
+ },
79
+ // Transcript — TWO schemas (D-200), resolved at capture time by readKiroTurn:
80
+ // • IDE: per-session JSON under globalStorage, base64url(workspacePath), a
81
+ // `history[]` array (the dir/key/parse below);
82
+ // • kiro-CLI: ~/.kiro/sessions/cli/<uuid>.json, matched by `cwd`, a
83
+ // `session_state.conversation_metadata.user_turn_metadatas[]` shape (the
84
+ // fallback path — readKiroCliTurn). The fields here describe the IDE shape;
85
+ // the CLI shape is handled by the readKiroTurn fallback, not a profile field.
86
+ transcript: {
87
+ dir: 'globalStorage/kiro.kiroagent/workspace-sessions',
88
+ workspaceKey: 'base64url',
89
+ parse: 'json-history',
90
+ },
91
+ });
92
+
93
+ // ── AGENTS.md (Task 50.G — the instruction-only breadth rung) ────────────────
94
+ // The cheap multi-tool reach: emit a managed block in AGENTS.md (the cross-tool
95
+ // instruction-file convention several non-Claude agents read — Cursor, Zed,
96
+ // Codex, gemini-cli, …). NO hooks, NO MCP — instruction surface only. For tools
97
+ // we haven't built a full adapter for; a thin rung, not the depth play. (D-180 §5.)
98
+ const agentsmd = defineAgentProfile({
99
+ name: 'agents-md',
100
+ displayName: 'AGENTS.md',
101
+ integrationType: 'instruction-only',
102
+ detect: { always: true }, // any repo can carry an AGENTS.md
103
+ instructionFile: 'AGENTS.md',
104
+ });
105
+
106
+ export const AGENT_PROFILES = Object.freeze({
107
+ 'claude-code': claudeCode,
108
+ kiro,
109
+ 'agents-md': agentsmd,
110
+ });
111
+
112
+ export function getAgentProfile(name) {
113
+ return AGENT_PROFILES[name];
114
+ }
115
+
116
+ export function listAgentProfiles() {
117
+ return Object.values(AGENT_PROFILES);
118
+ }
@@ -0,0 +1,92 @@
1
+ // PermissionRequest auto-approve logic — the prompt-free fix (Task 172).
2
+ //
3
+ // Background (the v0.4.1 cut-gate, 2026-06-27). Claude Code 2.1.x tightened
4
+ // permission matching so that neither the kit's `permissions.allow` MCP rules
5
+ // (`mcp__cmk__*` + the specific names, Task 171) NOR a skill's `allowed-tools`
6
+ // grant reliably suppress the per-tool MCP approval prompt or the "Use skill?"
7
+ // prompt — see anthropics/claude-code#17499 (allowed-tools for MCP tools is
8
+ // undocumented/unreliable) and #18837 → #14956 (allowed-tools not enforced).
9
+ // The popup is a CLAUDE CODE change, not a kit regression: the skill + allow-list
10
+ // config has been byte-stable since Task 108/117.
11
+ //
12
+ // The DOCUMENTED, working mechanism is a `PermissionRequest` hook
13
+ // (code.claude.com/docs/en/hooks-guide#auto-approve-specific-permission-prompts):
14
+ // it fires when CC is about to show a permission dialog, and a hook that writes
15
+ // `{"behavior":"allow"}` answers it on the user's behalf — proven live (v041l):
16
+ // the dialog flashes then auto-dismisses, the tool runs, no click required.
17
+ //
18
+ // This module decides — given the hook payload — whether to auto-approve. It
19
+ // approves ONLY the kit's OWN surfaces (its MCP tools + its scaffolded skills),
20
+ // never anything else. Two-layer safety: the wired matcher is already narrow
21
+ // (`mcp__cmk__.*` / `Skill`), and this self-check is the second layer, so even a
22
+ // loose matcher can't make the hook approve a non-kit tool. Returns `null` for
23
+ // anything not kit-owned → the hook stays silent → CC's normal permission flow
24
+ // runs unchanged.
25
+
26
+ // The kit's own scaffolded skills (template/.claude/skills/<name>). Keep in sync
27
+ // with the Skill(...) entries in settings-hooks.mjs KIT_ALLOW.
28
+ const KIT_SKILLS = Object.freeze(['memory-write', 'memory-search']);
29
+
30
+ // The allow decision shape CC expects on stdout for a PermissionRequest hook.
31
+ export const ALLOW_DECISION = Object.freeze({
32
+ hookSpecificOutput: {
33
+ hookEventName: 'PermissionRequest',
34
+ decision: { behavior: 'allow' },
35
+ },
36
+ });
37
+
38
+ /**
39
+ * True iff `toolName` is one of the kit's own MCP tools (any `mcp__cmk__<tool>`).
40
+ */
41
+ function isKitMcpTool(toolName) {
42
+ return typeof toolName === 'string' && toolName.startsWith('mcp__cmk__');
43
+ }
44
+
45
+ /**
46
+ * True iff this is an invocation of one of the kit's own scaffolded skills.
47
+ * The Skill tool surfaces the skill name either as the tool name's suffix
48
+ * (`Skill(memory-write)`) or in `tool_input.name`/`tool_input.skill` — accept
49
+ * either shape so a CC payload-format change doesn't silently stop matching.
50
+ *
51
+ * Security boundary: the `tool_input.name`/`skill` fallback is consulted ONLY
52
+ * when `tool_name` identifies the Skill tool (`Skill` or `Skill(...)`). Without
53
+ * that gate, a non-Skill request whose `tool_input` merely happened to carry
54
+ * `{name:"memory-write"}` (e.g. a `Bash` call) would match — defeating the
55
+ * second layer of defence-in-depth (the matcher is the first). We never read
56
+ * `tool_input` for a tool that isn't the Skill tool.
57
+ */
58
+ function isKitSkill(toolName, toolInput) {
59
+ if (typeof toolName !== 'string') return false;
60
+ // The documented tool-name form for a skill invocation: `Skill(<name>)`.
61
+ for (const skill of KIT_SKILLS) {
62
+ if (toolName === `Skill(${skill})`) return true;
63
+ }
64
+ // Only trust the tool_input name shape for an actual Skill-tool request.
65
+ // (A bare `tool_name === "<skill>"` is NOT matched — it isn't a documented
66
+ // CC shape and would risk approving any unrelated tool that happened to share
67
+ // the name.)
68
+ if (toolName === 'Skill' || toolName.startsWith('Skill(')) {
69
+ const named = toolInput && (toolInput.name ?? toolInput.skill ?? toolInput.skillName);
70
+ if (typeof named === 'string' && KIT_SKILLS.includes(named)) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Decide whether to auto-approve a PermissionRequest for a kit-owned surface.
77
+ *
78
+ * @param {object} payload - the hook payload from stdin
79
+ * ({ tool_name, tool_input, ... }).
80
+ * @returns {object|null} the ALLOW_DECISION object to print on stdout, or null
81
+ * when the request is NOT for a kit surface (the hook emits nothing → CC's
82
+ * normal permission flow proceeds).
83
+ */
84
+ export function evaluatePermissionRequest(payload) {
85
+ if (!payload || typeof payload !== 'object') return null;
86
+ const toolName = payload.tool_name;
87
+ const toolInput = payload.tool_input;
88
+ if (isKitMcpTool(toolName) || isKitSkill(toolName, toolInput)) {
89
+ return ALLOW_DECISION;
90
+ }
91
+ return null;
92
+ }
@@ -858,18 +858,25 @@ export async function runAutoExtract({
858
858
  // ceiling is free. Live-test finding (2026-06-01, live-test-4 baseline).
859
859
  timeoutMs: 90_000,
860
860
  });
861
- // Touch the cooldown marker IMMEDIATELY after the Haiku call
862
- // resolves — this is the "we spent the budget" signal that
863
- // compress-session.mjs reads to skip its own Haiku call within
864
- // 120s of ours. Touching on success only (not in the catch below)
865
- // would mean a failing Haiku in the auto-extract path doesn't
866
- // block compress-session which would then re-spend the budget
867
- // on the failure. The catch path below also touches.
861
+ // Touch the cooldown marker after a SUCCESSFUL Haiku call — the
862
+ // "we spent the budget" signal compress-session.mjs reads to skip its
863
+ // own Haiku call within 120s of ours.
864
+ //
865
+ // **Original behavior (pre-Task-167, preserved as decision-trail):** the
866
+ // catch block below ALSO touched the cooldown ("spent the budget,
867
+ // succeeded OR failed"), so a failing Haiku here blocked compress-session
868
+ // for 120s — on the theory that a failure shouldn't let the next caller
869
+ // re-spend the budget on the same broken call.
870
+ // **Task 167.F change (D-207, Q5):** the cooldown now fires on SUCCESS
871
+ // ONLY. A FAILED call did NOT successfully spend the budget — blocking the
872
+ // next NEEDED compress for 120s after a *transient* failure is the wrong
873
+ // gate (it was the SECONDARY cause of the 410 KB now.md bloat: after the
874
+ // dead cron, failed-call cooldowns kept the roll skipped). Correctness >
875
+ // cost — a transient failure must be free to retry.
868
876
  touchCooldownMarker({ projectRoot, now: ts });
869
877
  } catch (err) {
870
- // Spent the Haiku budget (succeeded OR failed); touch the
871
- // cooldown so compress-session skips within 120s.
872
- touchCooldownMarker({ projectRoot, now: ts });
878
+ // Task 167.F: do NOT touch the cooldown on failure (see the success-path
879
+ // comment above) a failed call must not block the next compress's retry.
873
880
  // Route on the error TYPE — distinguishes "took too long"
874
881
  // (HAIKU_TIMEOUT) from "subprocess exited non-zero"
875
882
  // (HAIKU_FAILED). Using `instanceof HaikuTimeoutError`
@@ -336,12 +336,16 @@ export async function autoPersona(opts = {}) {
336
336
  // ceiling, so it passes a generous value — the explicit command can wait.
337
337
  timeoutMs,
338
338
  });
339
- // Spent a Haiku call — refresh the shared cooldown marker so the next
340
- // gated caller backs off. (touch even on cooldownMs:0 cycles: the call
339
+ // Spent a Haiku call SUCCESSFULLY — refresh the shared cooldown marker so the
340
+ // next gated caller backs off. (touch even on cooldownMs:0 cycles: the call
341
341
  // happened, so the marker should reflect it for any LATER gated caller.)
342
342
  touchCooldownMarker({ projectRoot, now: ts });
343
343
  } catch (err) {
344
- touchCooldownMarker({ projectRoot, now: ts });
344
+ // Task 167.F (D-207, Q5): do NOT touch the cooldown on FAILURE — a failed
345
+ // Haiku call did not successfully spend the budget, and blocking the next
346
+ // needed compress for 120s after a transient failure is the wrong gate
347
+ // (correctness > cost; a transient failure must be free to retry). Original
348
+ // pre-167 behavior touched here too; preserved as decision-trail.
345
349
  return errorResult({
346
350
  category: ERROR_CATEGORIES.COMPRESS_FAILED,
347
351
  errors: [err?.message ?? String(err)],
@@ -421,7 +425,10 @@ export function appendPersonaReviewQueue({ userDir, entries, now }) {
421
425
  if (blocks.length === 0) return queuePath;
422
426
 
423
427
  const header = `## ${ts} — persona-synthesis (pending review)`;
424
- appendFileSync(queuePath, `${header}\n${blocks.join('\n')}\n\n`, 'utf8');
428
+ // Blank line below the heading (MD022). SAFE: parsePersonaReviewQueue keys on
429
+ // the bullet line + its i+1 meta-comment, not on heading adjacency; the blank
430
+ // is between heading and the first bullet, never inside the bullet↔comment pair.
431
+ appendFileSync(queuePath, `${header}\n\n${blocks.join('\n')}\n\n`, 'utf8');
425
432
  return queuePath;
426
433
  }
427
434
 
@@ -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
+ }
@@ -206,10 +206,22 @@ function restoreRolling(projectRoot, rollingPath) {
206
206
  function appendToTodayMd({ projectRoot, date, body }) {
207
207
  const path = todayMdPath(projectRoot, date);
208
208
  mkdirSync(dirname(path), { recursive: true });
209
+ // Lint-clean append (MD022 blanks-around-headings): a same-day re-append puts
210
+ // the new block's leading `## ` heading right after the prior block, with only
211
+ // the prior block's single trailing `\n` above it → heading not blank-
212
+ // surrounded. Guarantee exactly one blank line ABOVE the new block when the
213
+ // file already has content.
214
+ let prefix = '';
215
+ if (existsSync(path)) {
216
+ const existing = readFileSync(path, 'utf8');
217
+ if (existing.trim() !== '') {
218
+ prefix = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
219
+ }
220
+ }
209
221
  // Append with a trailing newline so successive same-day appends
210
222
  // don't collide on a missing terminator.
211
223
  const suffix = body.endsWith('\n') ? '' : '\n';
212
- appendFileSync(path, body + suffix, 'utf8');
224
+ appendFileSync(path, prefix + body + suffix, 'utf8');
213
225
  return path;
214
226
  }
215
227
 
@@ -21,9 +21,10 @@
21
21
  // (observations have their own provenance/shadowed_by surface, §6); this is
22
22
  // the concrete settings half the semantic default forced into existence.
23
23
 
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { mkdirSync, writeFileSync } from 'node:fs';
25
25
  import { dirname, join } from 'node:path';
26
26
  import { resolveTierRoot } from './tier-paths.mjs';
27
+ import { parseJsonFile } from './read-json.mjs';
27
28
 
28
29
  // Highest-precedence first.
29
30
  const TIERS = Object.freeze([
@@ -47,14 +48,11 @@ function settingsPathFor(tierName, { projectRoot, userDir }) {
47
48
  }
48
49
 
49
50
  function readSettings(path) {
50
- if (!existsSync(path)) return null;
51
- try {
52
- return JSON.parse(readFileSync(path, 'utf8'));
53
- } catch {
54
- // A malformed settings file is treated as absent for resolution — never
55
- // throw on a read (a hand-broken JSON shouldn't crash `cmk config get`).
56
- return null;
57
- }
51
+ // BOM-tolerant (parseJsonFile): a settings.json written by a Windows editor
52
+ // carries a UTF-8 BOM that a bare JSON.parse would reject (D-187). A missing
53
+ // OR malformed file resolves to null — never throw on a read (a hand-broken
54
+ // JSON shouldn't crash `cmk config get`).
55
+ return parseJsonFile(path, { fallback: null });
58
56
  }
59
57
 
60
58
  // Walk a dotted path; returns {found, value}. `found` distinguishes a key
@@ -33,6 +33,7 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
33
33
  import { join } from 'node:path';
34
34
  import { parse as parseFrontmatter } from './frontmatter.mjs';
35
35
  import { ID_PATTERN } from './tier-paths.mjs';
36
+ import { trimTrailingNewlines } from './managed-block.mjs';
36
37
 
37
38
  export const DECISIONS_HEADER =
38
39
  '# Decisions\n\n' +
@@ -62,9 +63,15 @@ function dateOnly(iso) {
62
63
  */
63
64
  export function buildDecisionEntry(f) {
64
65
  const date = dateOnly(f.createdAt);
66
+ // Blank lines around the `###` heading so the committed journal is lint-clean
67
+ // markdown (markdownlint MD022 "blanks-around-headings"). The HTML marker, the
68
+ // heading, and the When/Why block are each separated by a blank line — the
69
+ // generated memory passes a strict linter by construction, not by exemption.
65
70
  const lines = [
66
71
  markerFor(f.id),
67
- `### ${f.title}`,
72
+ '',
73
+ `## ${f.title}`,
74
+ '',
68
75
  `**When:** ${date} · **Fact:** \`${f.id}\``,
69
76
  ];
70
77
  if (f.why && String(f.why).trim()) {
@@ -129,8 +136,12 @@ export function updateDecisionsJournal({ existingContent = '', facts = [], tombs
129
136
  // from attaching the retraction note to the NEXT entry's heading.
130
137
  const nextMarker = content.indexOf('<!-- decision:', idx + marker.length);
131
138
  const spanEnd = nextMarker === -1 ? content.length : nextMarker;
132
- // Find this entry's heading line (the `### …` after the marker, within span).
133
- const headingStart = content.indexOf('### ', idx);
139
+ // Find this entry's heading line (the `## …` after the marker, within span).
140
+ // Anchor on a line-start `## ` (newline-prefixed) so body text containing
141
+ // `##` can't be mistaken for the heading. buildDecisionEntry emits the
142
+ // heading on its own line right after the marker + a blank line.
143
+ const headingNl = content.indexOf('\n## ', idx);
144
+ const headingStart = headingNl === -1 ? -1 : headingNl + 1;
134
145
  if (headingStart === -1 || headingStart >= spanEnd) continue;
135
146
  const headingEnd = content.indexOf('\n', headingStart);
136
147
  if (headingEnd === -1) continue;
@@ -145,6 +156,63 @@ export function updateDecisionsJournal({ existingContent = '', facts = [], tombs
145
156
  return content;
146
157
  }
147
158
 
159
+ /**
160
+ * Migrate an OLD-format journal (pre-Task-164.1: `### title` headings with no
161
+ * blank lines around them) to the lint-clean shape buildDecisionEntry now emits
162
+ * (`## title` with a blank line above + below). Append-only-safe: every entry +
163
+ * its content is preserved; only the heading level + surrounding blank lines
164
+ * change. Idempotent (already-clean content returns unchanged) and CRLF-tolerant
165
+ * (re-emits `\n`). Line-based (no backtracking regex — the ReDoS-safe discipline).
166
+ *
167
+ * The retraction tag (`_(retracted …)_`) sits on the line directly under the
168
+ * heading; it stays there (the blank-below goes AFTER the tag, matching the
169
+ * updateDecisionsJournal inserter).
170
+ *
171
+ * @param {string} content the DECISIONS.md content
172
+ * @returns {string} the normalized content (idempotent)
173
+ */
174
+ export function normalizeDecisionsJournal(content) {
175
+ if (typeof content !== 'string' || content === '') return content;
176
+ const lines = content.split(/\r?\n/);
177
+ const out = [];
178
+ for (let i = 0; i < lines.length; i += 1) {
179
+ const line = lines[i];
180
+ // A decision entry begins with the machine marker. Normalize the heading
181
+ // that follows it (skipping any blank lines already present).
182
+ if (/^<!-- decision:/.test(line)) {
183
+ out.push(line);
184
+ // ensure exactly one blank line after the marker
185
+ let j = i + 1;
186
+ while (j < lines.length && lines[j].trim() === '') j += 1; // skip existing blanks
187
+ out.push('');
188
+ // the heading line (### or ## title) — demote ### -> ##
189
+ if (j < lines.length && /^#{2,3}\s/.test(lines[j])) {
190
+ const headingText = lines[j].replace(/^#{2,3}\s+/, '');
191
+ out.push(`## ${headingText}`);
192
+ let k = j + 1;
193
+ // an optional retraction tag stays directly under the heading
194
+ if (k < lines.length && lines[k].trim().startsWith('_(retracted')) {
195
+ out.push(lines[k]);
196
+ k += 1;
197
+ }
198
+ // ensure exactly one blank line below the heading (or heading+tag)
199
+ while (k < lines.length && lines[k].trim() === '') k += 1; // collapse existing blanks
200
+ out.push('');
201
+ i = k - 1; // continue after the blank we just normalized
202
+ } else {
203
+ // malformed entry (marker with no heading) — leave the rest as-is
204
+ i = j - 1;
205
+ }
206
+ continue;
207
+ }
208
+ out.push(line);
209
+ }
210
+ // collapse any accidental >1 trailing blank, keep a single trailing newline
211
+ let result = out.join('\n');
212
+ result = trimTrailingNewlines(result) + '\n';
213
+ return result;
214
+ }
215
+
148
216
  // --- File-IO orchestration (the impure shell over the pure core) ----------
149
217
 
150
218
  // Leading indent is [ \t]* (NOT \s*) so it can't match the newline the