@lh8ppl/claude-memory-kit 0.3.4 → 0.4.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 (47) hide show
  1. package/README.md +6 -0
  2. package/bin/cmk-guard-memory.mjs +57 -0
  3. package/package.json +3 -2
  4. package/src/agent-profile.mjs +115 -0
  5. package/src/agent-profiles.mjs +118 -0
  6. package/src/auto-persona.mjs +4 -1
  7. package/src/compress-retry.mjs +25 -0
  8. package/src/compress-session.mjs +27 -3
  9. package/src/config-core.mjs +7 -9
  10. package/src/daily-distill.mjs +7 -3
  11. package/src/decisions-journal.mjs +71 -3
  12. package/src/doctor.mjs +86 -4
  13. package/src/guard-memory.mjs +151 -0
  14. package/src/import-anthropic-memory.mjs +15 -1
  15. package/src/inject-context.mjs +34 -3
  16. package/src/install-agent.mjs +220 -0
  17. package/src/install-kiro.mjs +287 -0
  18. package/src/install.mjs +16 -3
  19. package/src/kiro-cli-agent.mjs +270 -0
  20. package/src/kiro-constants.mjs +19 -0
  21. package/src/kiro-hook-bin.mjs +105 -0
  22. package/src/kiro-hook-command.mjs +67 -0
  23. package/src/kiro-hook-dispatch.mjs +115 -0
  24. package/src/kiro-ide-hooks.mjs +219 -0
  25. package/src/kiro-permissions.mjs +175 -0
  26. package/src/kiro-skills.mjs +96 -0
  27. package/src/kiro-transcript.mjs +366 -0
  28. package/src/kiro-trusted-commands.mjs +130 -0
  29. package/src/lazy-compress.mjs +6 -0
  30. package/src/managed-block.mjs +138 -0
  31. package/src/memory-write.mjs +23 -8
  32. package/src/mutate-agent-config.mjs +243 -0
  33. package/src/read-json.mjs +43 -0
  34. package/src/reindex.mjs +15 -2
  35. package/src/repair.mjs +39 -3
  36. package/src/result-shapes.mjs +8 -0
  37. package/src/review-queue.mjs +3 -0
  38. package/src/scratchpad.mjs +12 -2
  39. package/src/search.mjs +12 -5
  40. package/src/semantic-backend.mjs +7 -9
  41. package/src/settings-hooks.mjs +12 -2
  42. package/src/subcommands.mjs +360 -27
  43. package/src/tier-paths.mjs +48 -1
  44. package/src/weekly-curate.mjs +13 -6
  45. package/template/.claude/skills/memory-search/SKILL.md +14 -1
  46. package/template/.claude/skills/memory-write/SKILL.md +37 -1
  47. package/template/project/memory/INDEX.md.template +1 -1
package/README.md CHANGED
@@ -18,6 +18,7 @@
18
18
  - **Explicit capture when you want it** — say "remember this" / "from now on" / "we decided" / "forget X" (the `memory-write` skill), or run `cmk remember "<fact>"`. Both dedup, screen for secrets, abstract machine paths to `~`, and write silently. For backtick/quote-heavy rich facts, capture them shell-safe as JSON: `cmk remember --from-file fact.json` (or `--json` from stdin) — content never touches the shell.
19
19
  - **Search + MCP — Claude runs every memory op for you, in conversation** — `cmk search "<term>"` (keyword over facts + scratchpads; with the optional local embedder, **semantic + hybrid recall**: ask in your own words and get the fact even with zero keyword overlap — measured R@5 0.941 / paraphrase 1.000 on the kit's benchmark, no API calls). `cmk install` registers the kit's **MCP server**, so Claude can do the whole memory surface as tools without you ever typing `cmk`: capture (`mk_remember`, rich Why/How too), recall (`mk_search` / `mk_get` / `mk_timeline` / `mk_cite`), adjust trust (`mk_trust`), promote a fact across projects (`mk_lessons_promote`), forget (`mk_forget` — previews first, then deletes on confirm), and clear the review/conflict queues (`mk_queue_list` / `mk_queue_resolve`). The tools are allow-listed on install, so they run prompt-free.
20
20
  - **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows. The session-buffer rollup self-heals at session start too, so memory stays bounded even if you never cleanly close the window.
21
+ - **Guards your memory from an accidental delete** — `cmk install` wires a `PreToolUse` hook (`cmk-guard-memory`) that **blocks** a destructive command (`rm`, `Remove-Item`, `del`, `git clean`, `git reset --hard`, `find … -delete`, `truncate`, `>`-truncate) the moment it's aimed at a memory path — *before* it runs, on both Claude Code (`Bash` / `PowerShell`) and Kiro (`execute_bash`). Fail-open (a broken guard never wedges your session) and intentionally broad (a false block is recoverable; a false allow is the data loss it prevents). A safe command, or a delete of anything else, runs untouched.
21
22
  - **Don't start empty — import the rules you already own** — `cmk import-claude-md` parses an existing `CLAUDE.md` / `.cursorrules` / `AGENTS.md` into typed, searchable facts through the same safe write path (secret screening, sanitization, dedup), with provenance back to source file + line. `--dry-run` previews first.
22
23
  - **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
23
24
  - **9 health checks** — `cmk doctor` validates hook wiring, distill freshness, transcript firing, INDEX consistency, cron registration, native-memory coexistence, stale locks, native-binding health (npm 12 readiness), and version drift (a project scaffold behind your installed `cmk` after an update) — each failure with a repair command.
@@ -33,6 +34,7 @@ npm install -g @lh8ppl/claude-memory-kit
33
34
  cd ~/my-project
34
35
  cmk install # scaffolds context/ + the memory-write + memory-search skills AND wires the lifecycle hooks into .claude/settings.json
35
36
  cmk install --with-semantic # (optional) local semantic recall — one-time ~260 MB, search defaults to hybrid
37
+ cmk install --ide kiro # (optional) target Kiro instead — wires the IDE + kiro-cli surfaces (MCP + steering + AGENTS.md + skills + hooks)
36
38
  cmk register-crons # (optional) scheduled background compression — otherwise self-heals lazily
37
39
  cmk import-claude-md --yes # (optional) seed memory from an existing CLAUDE.md / .cursorrules (--dry-run previews)
38
40
  cmk doctor # verify, then restart Claude Code
@@ -42,6 +44,10 @@ cmk doctor # verify, then restart Claude Code
42
44
 
43
45
  > Installing the package globally adds the `cmk` CLI **and** the installer. It's the `cmk install` *subcommand* that wires the hooks — not the bare `npm install`.
44
46
 
47
+ **Other agents (Kiro).** `cmk install --ide kiro` targets [Kiro](https://kiro.dev) (AWS's agentic IDE + `kiro-cli`) instead of Claude Code — one command wires both its GUI hooks (`.kiro/hooks/`) and its terminal CLI agent (`~/.aws/amazonq/cli-agents/`), plus MCP, steering, skills, and an `AGENTS.md` instruction file. A project can carry **both** agents (run both installs — they share one `context/` brain and never clobber each other). Restart Kiro to load its hooks.
48
+
49
+ **Uninstall** is per-agent and conservative — it never deletes your `context/` memory: `cmk uninstall` removes the Claude Code surface; `cmk uninstall --ide kiro` removes the Kiro surface.
50
+
45
51
  ### Route B — Claude Code plugin marketplace
46
52
 
47
53
  Inside Claude Code:
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook handler — the memory delete-guardrail (D-192).
3
+ //
4
+ // Wired by `cmk install` as a Claude Code PreToolUse hook (matcher
5
+ // "Bash|PowerShell"). Reads the tool call on stdin and, if it's a destructive
6
+ // command aimed at a claude-memory-kit memory path (context/ , the persona
7
+ // tier, a memory file), BLOCKS it by exiting 2 — Claude Code shows the stderr
8
+ // reason to the model and the command never runs.
9
+ //
10
+ // Exit contract (Claude Code PreToolUse):
11
+ // exit 0 → allow (no opinion)
12
+ // exit 2 → BLOCK; stderr is surfaced as the reason
13
+ // Fail-OPEN: any load/parse error exits 0 — a broken guardrail must never wedge
14
+ // the session; it just stops guarding.
15
+
16
+ import { dirname, join } from 'node:path';
17
+ import { fileURLToPath, pathToFileURL } from 'node:url';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+ const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
22
+ const modulePath = join(__dirname, '..', 'src', 'guard-memory.mjs');
23
+
24
+ let readHookStdin;
25
+ let evaluatePayload;
26
+ try {
27
+ ({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
28
+ ({ evaluatePayload } = await import(pathToFileURL(modulePath).href));
29
+ } catch (err) {
30
+ process.stderr.write(`cmk-guard-memory: failed to load modules: ${err?.message ?? err}\n`);
31
+ process.exit(0); // fail-open
32
+ }
33
+
34
+ // Drain the hook payload — but not on an interactive TTY (a manual run), where
35
+ // a blocking stdin read would hang forever (the Task-101 lesson).
36
+ const raw = readHookStdin({ isTTY: process.stdin.isTTY });
37
+
38
+ let payload;
39
+ try {
40
+ payload = raw.trim() === '' ? {} : JSON.parse(raw);
41
+ } catch {
42
+ process.exit(0); // fail-open on unparseable input
43
+ }
44
+
45
+ let verdict;
46
+ try {
47
+ verdict = evaluatePayload(payload);
48
+ } catch {
49
+ process.exit(0); // fail-open on any logic error
50
+ }
51
+
52
+ if (verdict && verdict.block) {
53
+ process.stderr.write(`${verdict.reason}\n`);
54
+ process.exit(2); // BLOCK
55
+ }
56
+
57
+ process.exit(0); // allow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lh8ppl/claude-memory-kit",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "cmk — the CLI for claude-memory-kit. Per-project, in-repo memory system for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "cmk-capture-prompt": "./bin/cmk-capture-prompt.mjs",
13
13
  "cmk-observe-edit": "./bin/cmk-observe-edit.mjs",
14
14
  "cmk-capture-turn": "./bin/cmk-capture-turn.mjs",
15
- "cmk-compress-session": "./bin/cmk-compress-session.mjs"
15
+ "cmk-compress-session": "./bin/cmk-compress-session.mjs",
16
+ "cmk-guard-memory": "./bin/cmk-guard-memory.mjs"
16
17
  },
17
18
  "files": [
18
19
  "bin/",
@@ -0,0 +1,115 @@
1
+ // agent-profile.mjs — the per-agent profile factory (Task 50.C).
2
+ //
3
+ // D-180: per-agent adapters are DATA, not classes. This factory validates +
4
+ // normalizes a profile DECLARATION so the install routing (Task 50.F) can drive
5
+ // ANY agent through ONE code path — the config legs (MCP registration, hook
6
+ // entry) go through the shared `mutateAgentConfig` primitive (Task 50.B); the
7
+ // instruction leg goes through the kit's marker-block machinery. The factory
8
+ // itself does NO I/O — it's a pure validator/normalizer that returns a frozen
9
+ // descriptor. A bad profile fails LOUD at definition time (throws), not at
10
+ // install time against a user's machine.
11
+ //
12
+ // Integration-type taxonomy (the claude-mem insight — the type dictates which
13
+ // legs an agent wires):
14
+ // native-hooks-mcp — instruction + MCP + lifecycle hooks (Claude Code, Kiro)
15
+ // hooks-mcp — instruction + MCP + hooks, hooks via a dedicated file
16
+ // (Cursor) — same required legs as native-hooks-mcp;
17
+ // the `hooks.mechanism` field distinguishes them
18
+ // mcp-only — instruction + MCP, NO hooks (Copilot/Warp/Roo/Goose)
19
+ // instruction-only — instruction file only, NO MCP, NO hooks (AGENTS.md rung)
20
+ //
21
+ // Public surface:
22
+ // defineAgentProfile(declaration) → frozen normalized descriptor (throws on invalid)
23
+ // INTEGRATION_TYPES — frozen list of the valid integrationType values
24
+
25
+ export const INTEGRATION_TYPES = Object.freeze([
26
+ 'native-hooks-mcp',
27
+ 'hooks-mcp',
28
+ 'mcp-only',
29
+ 'instruction-only',
30
+ ]);
31
+
32
+ const TYPES = new Set(INTEGRATION_TYPES);
33
+
34
+ // Which legs each type REQUIRES / FORBIDS. instructionFile is required by every
35
+ // type (the universal leg). mcp + hooks vary by type.
36
+ const TYPE_LEGS = Object.freeze({
37
+ 'native-hooks-mcp': { mcp: 'required', hooks: 'required' },
38
+ 'hooks-mcp': { mcp: 'required', hooks: 'required' },
39
+ 'mcp-only': { mcp: 'required', hooks: 'forbidden' },
40
+ 'instruction-only': { mcp: 'forbidden', hooks: 'forbidden' },
41
+ });
42
+
43
+ function fail(msg) {
44
+ throw new Error(`defineAgentProfile: ${msg}`);
45
+ }
46
+
47
+ /**
48
+ * Validate + normalize an agent profile declaration.
49
+ * @param {object} decl
50
+ * @returns {Readonly<object>} the frozen descriptor
51
+ */
52
+ export function defineAgentProfile(decl) {
53
+ if (decl === null || typeof decl !== 'object') {
54
+ fail('declaration must be an object');
55
+ }
56
+ const { name, displayName, integrationType, detect, instructionFile, mcp, hooks, transcript } = decl;
57
+
58
+ // ── universal required fields ────────────────────────────────────────────
59
+ if (typeof name !== 'string' || name.length === 0) {
60
+ fail('name is required (non-empty string)');
61
+ }
62
+ if (!TYPES.has(integrationType)) {
63
+ fail(`integrationType must be one of: ${INTEGRATION_TYPES.join(', ')} (got ${JSON.stringify(integrationType)})`);
64
+ }
65
+ if (detect === null || typeof detect !== 'object') {
66
+ fail(`profile ${name}: detect descriptor is required (e.g. {homeDir:'.kiro'} | {command:'x'} | {always:true})`);
67
+ }
68
+ if (typeof instructionFile !== 'string' || instructionFile.length === 0) {
69
+ fail(`profile ${name}: instructionFile is required (every integration type wires the instruction leg)`);
70
+ }
71
+
72
+ // ── per-type leg contract (the parity invariant 50.D will also enforce) ──
73
+ const legs = TYPE_LEGS[integrationType];
74
+ enforceLeg(name, integrationType, 'mcp', mcp, legs.mcp);
75
+ enforceLeg(name, integrationType, 'hooks', hooks, legs.hooks);
76
+
77
+ // ── shape checks on present legs ─────────────────────────────────────────
78
+ if (mcp !== undefined) {
79
+ if (typeof mcp.path !== 'string' || typeof mcp.serversKey !== 'string') {
80
+ fail(`profile ${name}: mcp requires {path, serversKey} strings`);
81
+ }
82
+ }
83
+ if (hooks !== undefined) {
84
+ if (typeof hooks.mechanism !== 'string') {
85
+ fail(`profile ${name}: hooks requires a {mechanism} string`);
86
+ }
87
+ if (hooks.eventMap === null || typeof hooks.eventMap !== 'object') {
88
+ fail(`profile ${name}: hooks requires an {eventMap} object`);
89
+ }
90
+ }
91
+
92
+ // ── normalize + freeze ───────────────────────────────────────────────────
93
+ const descriptor = {
94
+ name,
95
+ displayName: displayName || name,
96
+ integrationType,
97
+ detect: Object.freeze({ ...detect }),
98
+ instructionFile,
99
+ ...(mcp !== undefined ? { mcp: Object.freeze({ ...mcp }) } : {}),
100
+ ...(hooks !== undefined
101
+ ? { hooks: Object.freeze({ ...hooks, eventMap: Object.freeze({ ...hooks.eventMap }) }) }
102
+ : {}),
103
+ ...(transcript !== undefined ? { transcript: Object.freeze({ ...transcript }) } : {}),
104
+ };
105
+ return Object.freeze(descriptor);
106
+ }
107
+
108
+ function enforceLeg(name, type, leg, value, requirement) {
109
+ if (requirement === 'required' && value === undefined) {
110
+ fail(`profile ${name}: integrationType '${type}' requires the ${leg} leg`);
111
+ }
112
+ if (requirement === 'forbidden' && value !== undefined) {
113
+ fail(`profile ${name}: integrationType '${type}' must NOT declare ${leg} (it over-wires its type)`);
114
+ }
115
+ }
@@ -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
+ }
@@ -421,7 +421,10 @@ export function appendPersonaReviewQueue({ userDir, entries, now }) {
421
421
  if (blocks.length === 0) return queuePath;
422
422
 
423
423
  const header = `## ${ts} — persona-synthesis (pending review)`;
424
- appendFileSync(queuePath, `${header}\n${blocks.join('\n')}\n\n`, 'utf8');
424
+ // Blank line below the heading (MD022). SAFE: parsePersonaReviewQueue keys on
425
+ // the bullet line + its i+1 meta-comment, not on heading adjacency; the blank
426
+ // is between heading and the first bullet, never inside the bullet↔comment pair.
427
+ appendFileSync(queuePath, `${header}\n\n${blocks.join('\n')}\n\n`, 'utf8');
425
428
  return queuePath;
426
429
  }
427
430
 
@@ -37,6 +37,31 @@
37
37
  // a time, gated by the cooldown), so there is no herd to avoid — a plain exponential
38
38
  // backoff is sufficient and keeps the timing deterministic for tests.
39
39
 
40
+ // The timeout for compress callers that have NO outer hook ceiling — the cron /
41
+ // detached-lazy children (daily-distill, weekly-curate, the lazy compressSession).
42
+ // The D-92/F-2 composition rule: a ceiling-free caller must NOT inherit the
43
+ // hook-sized 50s bound (which is sized under the 60s SessionEnd ceiling, §8.5).
44
+ // 120s is chosen against MEASURED `claude --print` latency: it runs ~18-27s when
45
+ // fast but was observed at 78s (Task 163 live, on a 4.7KB input) and 89s (the v0.3.4
46
+ // cut-gate, 10KB) in slow-Haiku windows — environmental, not size-driven (D-174).
47
+ // 120s clears those with headroom; the 50s budget killed them needlessly, leaving
48
+ // `recent.md` stale (D-179). One constant so the family can't drift back to 50s.
49
+ // (auto-extract uses its own 90s under the Stop hook — a separate detached path.)
50
+ export const CEILING_FREE_TIMEOUT_MS = 120_000;
51
+
52
+ // The backoff BETWEEN retries on the ceiling-free paths. The default baseBackoffMs
53
+ // (600ms) is far too short for the kit's failure mode: `claude --print` slowness is
54
+ // a transient WINDOW (slow for a stretch, then fine — D-174), and the whole point of
55
+ // backoff is to let that window PASS before retrying. A 600ms wait retries while
56
+ // still INSIDE the same slow window, so attempt 2 hits the same slowness and also
57
+ // times out. The field waits SECONDS for exactly this reason (graphiti 5-120s,
58
+ // Letta cap 10s, mempalace 2-8s — all checked across 19 systems; NONE use sub-second
59
+ // backoff, and NONE escalate the timeout itself). 5s is the field's low end — one
60
+ // 5s wait between the 2 ceiling-free attempts gives the slow window room to clear.
61
+ // Safe on every path: the HOOK path is maxAttempts:1 (no retry → backoff never
62
+ // fires); the ceiling-free paths run detached/cron, so a multi-second wait is free.
63
+ export const CEILING_FREE_BACKOFF_MS = 5_000;
64
+
40
65
  /**
41
66
  * Classify a compress() rejection as transient (worth a retry) or deterministic
42
67
  * (a re-call re-fails identically — don't waste the attempt or the budget).
@@ -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
 
@@ -232,6 +244,16 @@ export async function compressSession({
232
244
  // caller (runLazyCompress) passes maxAttempts:2 to opt into one retry; the hook
233
245
  // keeps its restore-on-failure (D-79) and delegates the retry to that lazy path.
234
246
  maxAttempts = 1,
247
+ // DEFAULT 50s = the SessionEnd-hook budget (sized under the 60s ceiling, §8.5).
248
+ // The ceiling-free LAZY caller (runLazyCompress, a detached SessionStart child
249
+ // with NO outer ceiling) passes 120s so a slow-but-not-broken `claude --print`
250
+ // window doesn't time out needlessly — the D-92/F-2 composition rule: a
251
+ // ceiling-free caller must not inherit a ceiling-sized timeout.
252
+ timeoutMs = 50_000,
253
+ // Backoff between retries (only the lazy maxAttempts:2 path retries). DEFAULT
254
+ // undefined → compressWithRetry's 600ms; the ceiling-free LAZY caller passes the
255
+ // 5s ceiling-free backoff so a retry lands AFTER the slow-Haiku window (D-179).
256
+ baseBackoffMs,
235
257
  } = {}) {
236
258
  const ts = now ?? nowIso();
237
259
  const date = dateFromIso(ts);
@@ -343,9 +365,11 @@ export async function compressSession({
343
365
  instructions,
344
366
  preserveCitationIds: true,
345
367
  maxOutputBytes,
346
- timeoutMs: 50_000,
368
+ timeoutMs,
347
369
  },
348
- { maxAttempts, onRetry: () => { retries += 1; } },
370
+ // baseBackoffMs only forwarded when the caller set it (the lazy ceiling-free
371
+ // path passes the 5s backoff); undefined → compressWithRetry's 600ms default.
372
+ { maxAttempts, ...(baseBackoffMs != null ? { baseBackoffMs } : {}), onRetry: () => { retries += 1; } },
349
373
  );
350
374
  } catch (err) {
351
375
  // Distinguish HAIKU_TIMEOUT (slow Anthropic) from COMPRESS_FAILED
@@ -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
@@ -28,7 +28,7 @@ import { join } from 'node:path';
28
28
  import { nowIso } from './audit-log.mjs';
29
29
  import { ERROR_CATEGORIES } from './result-shapes.mjs';
30
30
  import { HaikuTimeoutError } from './compressor.mjs';
31
- import { compressWithRetry } from './compress-retry.mjs';
31
+ import { compressWithRetry, CEILING_FREE_TIMEOUT_MS, CEILING_FREE_BACKOFF_MS } from './compress-retry.mjs';
32
32
  import {
33
33
  DEFAULT_COOLDOWN_MS,
34
34
  isCooldownActive,
@@ -209,9 +209,13 @@ export async function dailyDistill({
209
209
  instructions,
210
210
  preserveCitationIds: true,
211
211
  maxOutputBytes,
212
- timeoutMs: 50_000,
212
+ // Ceiling-free (cron / detached lazy child, NO 60s hook ceiling) → the
213
+ // generous ceiling-free timeout, NOT the hook-sized 50s (D-92/F-2 + D-179).
214
+ timeoutMs: CEILING_FREE_TIMEOUT_MS,
213
215
  },
214
- { maxAttempts: 2, onRetry: () => { retries += 1; } },
216
+ // 5s backoff between the 2 attempts (NOT the 600ms default) so a retry lands
217
+ // AFTER the transient slow-Haiku window, not inside it (D-179).
218
+ { maxAttempts: 2, baseBackoffMs: CEILING_FREE_BACKOFF_MS, onRetry: () => { retries += 1; } },
215
219
  );
216
220
  touchCooldownMarker({ projectRoot, now: ts });
217
221
  } catch (err) {
@@ -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