@lh8ppl/claude-memory-kit 0.3.5 → 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 (44) 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-session.mjs +13 -1
  8. package/src/config-core.mjs +7 -9
  9. package/src/decisions-journal.mjs +71 -3
  10. package/src/doctor.mjs +86 -4
  11. package/src/guard-memory.mjs +151 -0
  12. package/src/import-anthropic-memory.mjs +15 -1
  13. package/src/inject-context.mjs +34 -3
  14. package/src/install-agent.mjs +220 -0
  15. package/src/install-kiro.mjs +287 -0
  16. package/src/install.mjs +16 -3
  17. package/src/kiro-cli-agent.mjs +270 -0
  18. package/src/kiro-constants.mjs +19 -0
  19. package/src/kiro-hook-bin.mjs +105 -0
  20. package/src/kiro-hook-command.mjs +67 -0
  21. package/src/kiro-hook-dispatch.mjs +115 -0
  22. package/src/kiro-ide-hooks.mjs +219 -0
  23. package/src/kiro-permissions.mjs +175 -0
  24. package/src/kiro-skills.mjs +96 -0
  25. package/src/kiro-transcript.mjs +366 -0
  26. package/src/kiro-trusted-commands.mjs +130 -0
  27. package/src/managed-block.mjs +138 -0
  28. package/src/memory-write.mjs +23 -8
  29. package/src/mutate-agent-config.mjs +243 -0
  30. package/src/read-json.mjs +43 -0
  31. package/src/reindex.mjs +15 -2
  32. package/src/repair.mjs +39 -3
  33. package/src/result-shapes.mjs +8 -0
  34. package/src/review-queue.mjs +3 -0
  35. package/src/scratchpad.mjs +12 -2
  36. package/src/search.mjs +12 -5
  37. package/src/semantic-backend.mjs +7 -9
  38. package/src/settings-hooks.mjs +12 -2
  39. package/src/subcommands.mjs +360 -27
  40. package/src/tier-paths.mjs +48 -1
  41. package/src/weekly-curate.mjs +6 -2
  42. package/template/.claude/skills/memory-search/SKILL.md +14 -1
  43. package/template/.claude/skills/memory-write/SKILL.md +37 -1
  44. 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.5",
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
 
@@ -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
package/src/doctor.mjs CHANGED
@@ -48,6 +48,8 @@ import { checkKitBinding, checkEmbedderBinding } from './native-binding.mjs';
48
48
  import { resolveDefaultSearchMode } from './semantic-backend.mjs';
49
49
  import { checkVersionDrift } from './version-drift.mjs';
50
50
  import { getKitVersion } from './install.mjs';
51
+ import { hasOurCliAgent } from './kiro-cli-agent.mjs';
52
+ import { stripBom } from './read-json.mjs';
51
53
 
52
54
  const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
53
55
  const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
@@ -59,9 +61,41 @@ const MEMORY_DIR_REL = ['context', 'memory'];
59
61
  const LOCKS_REL = ['context', '.locks'];
60
62
  const NATIVE_MEMORY_LOG_REL = ['context', '.locks', 'native-memory-status.log'];
61
63
 
64
+ // Which agent was this project installed for? A `--ide kiro` install wires
65
+ // .kiro/ surfaces (hooks + steering/cmk.md + skills + settings/mcp.json); a
66
+ // Claude Code install wires .claude/settings.json. HC-1 must check the RIGHT
67
+ // surface — before v0.4.0 it hard-checked .claude/settings.json and false-FAILed
68
+ // on every Kiro install (the cut-gate-kiro live-test find, Task 50).
69
+ //
70
+ // Detection precedence:
71
+ // 1. .claude/settings.json present → Claude Code.
72
+ // 2. a CMK-OWNED Kiro marker present (.kiro/steering/cmk.md — written by every
73
+ // installKiro run) → Kiro. We key on OUR marker, not a bare `.kiro/` dir, so
74
+ // a stray/unrelated .kiro/ (another tool's, or a partial non-cmk dir) does
75
+ // NOT flip the project to the Kiro path (I2).
76
+ // 3. neither → Claude Code (historical default — a not-yet-installed project
77
+ // still reports the Claude-shaped repair hint).
78
+ //
79
+ // NOTE (I1, deliberate punt): a project installed for BOTH agents (.claude AND a
80
+ // cmk .kiro marker) resolves to Claude Code by precedence — HC-1 checks only the
81
+ // Claude surface. Dual-install is rare; the single-surface check is intentional
82
+ // for v0.4.0, not an oversight. Revisit if dual-install becomes common.
83
+ function detectInstallKind(projectRoot) {
84
+ if (existsSync(join(projectRoot, '.claude', 'settings.json'))) return 'claude-code';
85
+ if (existsSync(join(projectRoot, '.kiro', 'steering', 'cmk.md'))) return 'kiro';
86
+ return 'claude-code';
87
+ }
88
+
62
89
  // --- HC-1: Stop + SessionStart hooks registered -----------------------
63
- function hc1Hooks({ projectRoot }) {
64
- // Per design §5 the kit's hooks live in .claude/settings.json
90
+ function hc1Hooks({ projectRoot, awsDir }) {
91
+ // Agent-aware (v0.4.0): a Kiro install keeps its hooks in .kiro/hooks/ (IDE)
92
+ // and/or ~/.kiro/agents/ (kiro-cli), so route to the Kiro check
93
+ // rather than false-failing on a missing .claude/settings.json with a
94
+ // Claude-Code repair hint.
95
+ if (detectInstallKind(projectRoot) === 'kiro') {
96
+ return hc1KiroHooks({ projectRoot, awsDir });
97
+ }
98
+ // Per design §5 — the Claude Code hooks live in .claude/settings.json
65
99
  // alongside its plugin manifest. Required for auto-extract +
66
100
  // session-end compression to fire.
67
101
  const settingsPath = join(projectRoot, '.claude', 'settings.json');
@@ -76,7 +110,9 @@ function hc1Hooks({ projectRoot }) {
76
110
  }
77
111
  let settings;
78
112
  try {
79
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
113
+ // stripBom: a Windows-editor BOM on .claude/settings.json must not make a
114
+ // valid file read as a parse error → false HC-1 FAIL (D-187).
115
+ settings = JSON.parse(stripBom(readFileSync(settingsPath, 'utf8')));
80
116
  } catch (err) {
81
117
  return {
82
118
  id: 'HC-1',
@@ -141,6 +177,51 @@ function hc1Hooks({ projectRoot }) {
141
177
  };
142
178
  }
143
179
 
180
+ // --- HC-1 (Kiro variant): capture + inject can fire via EITHER Kiro surface ----
181
+ // Kiro wires capture/inject through TWO independent surfaces (D-186):
182
+ // • IDE hooks → .kiro/hooks/{cmk-capture,cmk-inject}.kiro.hook (the GUI user)
183
+ // • CLI agent → ~/.kiro/agents/cmk.json with
184
+ // agentSpawn(inject)+stop(capture) hooks (the kiro-cli user)
185
+ // HC-1 is a CAPABILITY check ("can capture/inject fire?"), not a single-file
186
+ // check — so it PASSES if EITHER surface is present, and FAILs only when NEITHER
187
+ // is. The original v0.4.0 fix checked only the IDE hooks, which false-FAILed a
188
+ // working kiro-cli-only install (the surface lives in ~/.aws, which is also
189
+ // machine-local and doesn't travel with a clone) — the same separately-correct-
190
+ // jointly-broken class as D-184/D-185, one level down (skill-review B1).
191
+ // `awsDir` is injectable so tests can sandbox the ~/.aws probe.
192
+ function hc1KiroHooks({ projectRoot, awsDir }) {
193
+ const hooksDir = join(projectRoot, '.kiro', 'hooks');
194
+ const ideHooks = ['cmk-capture.kiro.hook', 'cmk-inject.kiro.hook'];
195
+ const ideMissing = ideHooks.filter((f) => !existsSync(join(hooksDir, f)));
196
+ const ideComplete = ideMissing.length === 0;
197
+ const cliAgent = hasOurCliAgent({ awsDir });
198
+
199
+ if (ideComplete || cliAgent) {
200
+ const surfaces = [];
201
+ if (ideComplete) surfaces.push('IDE hooks (.kiro/hooks/)');
202
+ if (cliAgent) surfaces.push('CLI agent (~/.kiro/agents/cmk.json)');
203
+ return {
204
+ id: 'HC-1',
205
+ name: 'Stop + SessionStart hooks registered',
206
+ status: 'pass',
207
+ message: `Kiro capture/inject wired via ${surfaces.join(' + ')}`,
208
+ };
209
+ }
210
+
211
+ // Neither surface present → genuinely not wired. Name both so a kiro-cli user
212
+ // isn't pushed at the IDE-only repair.
213
+ return {
214
+ id: 'HC-1',
215
+ name: 'Stop + SessionStart hooks registered',
216
+ status: 'fail',
217
+ message:
218
+ `Kiro install: no capture/inject surface found — neither the IDE hooks ` +
219
+ `(${ideMissing.join(', ')} in .kiro/hooks/) nor a cmk CLI agent in ` +
220
+ `~/.kiro/agents/`,
221
+ recoveryCommand: 'cmk install --ide kiro',
222
+ };
223
+ }
224
+
144
225
  // --- HC-2: distill freshness (≤2 days) --------------------------------
145
226
  function hc2DistillFreshness({ projectRoot, now }) {
146
227
  const recentPath = join(projectRoot, ...RECENT_MD_REL);
@@ -566,6 +647,7 @@ export async function runDoctor({
566
647
  kitVersion,
567
648
  kitBindingProbe,
568
649
  embedderBindingProbe,
650
+ awsDir, // injectable: sandboxes the HC-1 Kiro CLI-agent (~/.aws) probe in tests
569
651
  } = {}) {
570
652
  const t0 = Date.now();
571
653
  if (!projectRoot) {
@@ -580,7 +662,7 @@ export async function runDoctor({
580
662
  const resolvedUserDir = userDir ?? join(homedir(), '.claude-memory-kit');
581
663
 
582
664
  // Run all checks in order.
583
- const c1 = hc1Hooks({ projectRoot });
665
+ const c1 = hc1Hooks({ projectRoot, awsDir });
584
666
  const c2 = hc2DistillFreshness({ projectRoot, now: ts });
585
667
  const c3 = hc3Transcripts({ projectRoot, now: ts });
586
668
  const c4 = hc4IndexConsistency({ projectRoot });