@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/src/repair.mjs CHANGED
@@ -16,8 +16,11 @@
16
16
  // Per design §14 + tasks.md 39 (39.1–39.3).
17
17
 
18
18
  import {
19
+ existsSync,
20
+ readFileSync,
19
21
  statSync,
20
22
  unlinkSync,
23
+ writeFileSync,
21
24
  } from 'node:fs';
22
25
  import { join } from 'node:path';
23
26
  import {
@@ -204,6 +207,34 @@ async function repairIndex({ projectRoot, userDir, reindexer }) {
204
207
  }
205
208
  }
206
209
 
210
+ /**
211
+ * Migrate committed memory markdown to the lint-clean shape (Task 164.9) — so a
212
+ * repo installed before the lint-clean generators (164.1–164.7) brings its
213
+ * existing memory up to the clean format a default markdownlint passes. Today
214
+ * this normalizes `context/DECISIONS.md` (old `### ` entries → `## ` +
215
+ * blank-surrounded). Idempotent + CRLF-tolerant: a no-op on already-clean
216
+ * content (changed:false), so it's safe to run repeatedly. ONLY rewrites a file
217
+ * whose normalized form actually differs — never a gratuitous write.
218
+ */
219
+ async function repairMemoryFormat({ projectRoot }) {
220
+ const { normalizeDecisionsJournal } = await import('./decisions-journal.mjs');
221
+ const decisionsPath = join(projectRoot, 'context', 'DECISIONS.md');
222
+ if (!existsSync(decisionsPath)) {
223
+ return { kind: 'format', changed: false, detail: 'no DECISIONS.md' };
224
+ }
225
+ try {
226
+ const before = readFileSync(decisionsPath, 'utf8');
227
+ const after = normalizeDecisionsJournal(before);
228
+ if (after === before) {
229
+ return { kind: 'format', changed: false, detail: 'DECISIONS.md already lint-clean' };
230
+ }
231
+ writeFileSync(decisionsPath, after, 'utf8');
232
+ return { kind: 'format', changed: true, detail: 'DECISIONS.md migrated to lint-clean headings' };
233
+ } catch (err) {
234
+ return { kind: 'format', changed: false, error: `format migration failed: ${err?.message ?? err}` };
235
+ }
236
+ }
237
+
207
238
  /**
208
239
  * Public boundary: run the repair pipeline.
209
240
  *
@@ -226,15 +257,15 @@ export async function runRepair({
226
257
  duration_ms: Date.now() - t0,
227
258
  });
228
259
  }
229
- if (!['hooks', 'locks', 'index', 'all'].includes(scope)) {
260
+ if (!['hooks', 'locks', 'index', 'format', 'all'].includes(scope)) {
230
261
  return errorResult({
231
262
  category: ERROR_CATEGORIES.SCHEMA,
232
- errors: [`invalid scope: ${scope}; expected 'hooks' | 'locks' | 'index' | 'all'`],
263
+ errors: [`invalid scope: ${scope}; expected 'hooks' | 'locks' | 'index' | 'format' | 'all'`],
233
264
  duration_ms: Date.now() - t0,
234
265
  });
235
266
  }
236
267
 
237
- const scopes = scope === 'all' ? ['hooks', 'locks', 'index'] : [scope];
268
+ const scopes = scope === 'all' ? ['hooks', 'locks', 'index', 'format'] : [scope];
238
269
  const repairs = [];
239
270
  let errors = 0;
240
271
  for (const s of scopes) {
@@ -251,6 +282,11 @@ export async function runRepair({
251
282
  const r = await repairIndex({ projectRoot, userDir, reindexer });
252
283
  if (r.error) errors += 1;
253
284
  repairs.push(r);
285
+ } else if (s === 'format') {
286
+ // eslint-disable-next-line no-await-in-loop
287
+ const r = await repairMemoryFormat({ projectRoot });
288
+ if (r.error) errors += 1;
289
+ repairs.push(r);
254
290
  }
255
291
  }
256
292
  return {
@@ -110,6 +110,14 @@ export const ERROR_CATEGORIES = Object.freeze({
110
110
  // NO silent fallback to keyword — the user asked for semantic,
111
111
  // and the surface should fail-loud so they know what's missing.
112
112
  SEMANTIC_UNAVAILABLE: 'semantic_unavailable',
113
+
114
+ // A per-agent config file (Task 50, cross-agent install) could not be
115
+ // PARSED — it exists but its bytes aren't valid for the declared format
116
+ // (corrupt/hand-edited JSON, etc.). `mutateAgentConfig` returns this
117
+ // and REFUSES to write, so a malformed third-party config is never
118
+ // clobbered (the claude-mem rigor-drift bug class, inverted into a
119
+ // guarantee — design: cross-agent adapter seam note 2026-06-20 / D-180).
120
+ CONFIG_PARSE: 'config_parse',
113
121
  });
114
122
 
115
123
  export const ACTION_TYPES = Object.freeze({
@@ -166,6 +166,9 @@ function serializeReviewQueue({ preamble, entries }) {
166
166
  const lines = [...preamble];
167
167
  for (const e of entries) {
168
168
  lines.push(`## ${e.ts} — auto-extract (medium-trust, pending review)`);
169
+ lines.push(''); // blank line below the heading (MD022). SAFE: parseReviewQueue
170
+ // scans i+1..i+5 for the bullet, so a blank heading→bullet gap is tolerated;
171
+ // the provenance stays at bulletLine+1 (the pair is never split).
169
172
  lines.push(`- (${e.id}) ${e.text}`);
170
173
  if (e.provenance) lines.push(e.provenance);
171
174
  lines.push('');
@@ -215,7 +215,13 @@ export function ensureSectionExists(scratchpadPath, sectionTitle) {
215
215
  // No leading blank lines for an empty/whitespace-only file (the scaffolded
216
216
  // scratchpads are never empty, but keep the output clean if one ever is).
217
217
  const prefix = body ? `${body}\n\n` : '';
218
- writeFileSync(scratchpadPath, `${prefix}## ${sectionTitle}\n`, 'utf8');
218
+ // Blank line AFTER the heading too (MD022 blanks-around-headings) the first
219
+ // bullet appended into this section then lands after the blank, so the
220
+ // committed scratchpad is lint-clean. SAFE for readers: findSectionRange uses
221
+ // a whole-line trim-compare + insertIntoSection skips trailing blanks, so a
222
+ // blank under the heading doesn't change where bullets insert. The blank is
223
+ // between the HEADING and the bullet — never inside the bullet↔comment pair.
224
+ writeFileSync(scratchpadPath, `${prefix}## ${sectionTitle}\n\n`, 'utf8');
219
225
  return { created: true };
220
226
  }
221
227
 
@@ -271,7 +277,11 @@ function archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted, now }) {
271
277
  const archivePath = join(archiveDir, 'evicted-bullets.md');
272
278
  const ts = now ?? nowIso();
273
279
  const header = existsSync(archivePath) ? '' : EVICTED_ARCHIVE_HEADER;
274
- const block = `## Evicted ${ts} consolidate(${scratchpad})\n${evicted
280
+ // Blank line after the heading so the archive is lint-clean markdown (MD022
281
+ // blanks-around-headings) — generated memory passes a strict linter by
282
+ // construction. The preceding header (or the prior block's trailing `\n\n`)
283
+ // supplies the blank line above.
284
+ const block = `## Evicted ${ts} — consolidate(${scratchpad})\n\n${evicted
275
285
  .map((e) => e.block)
276
286
  .join('\n')}\n\n`;
277
287
  appendFileSync(archivePath, header + block, 'utf8');
package/src/search.mjs CHANGED
@@ -481,12 +481,19 @@ function runDecisionsKeywordSearch(_db, opts) {
481
481
  // The line offset of the marker = source_line drill-back into DECISIONS.md.
482
482
  const sourceLine = content.slice(0, start).split('\n').length;
483
483
  // Retracted-tag detection mirrors the WRITER's contract: the tag sits on its
484
- // own line DIRECTLY after the `### ` heading (decisions-journal.mjs §2), so
485
- // scope the check there NOT a raw-block substring, which would mislabel an
486
- // active entry whose Why merely MENTIONS "_(retracted" (skill-review I1).
487
- const headingIdx = block.indexOf('### ');
484
+ // own line DIRECTLY after the `## ` heading (decisions-journal.mjs §2
485
+ // buildDecisionEntry emits `## ` h2 entries; the retract inserter puts the
486
+ // tag at headingEnd+1), so scope the check there — NOT a raw-block substring,
487
+ // which would mislabel an active entry whose Why merely MENTIONS "_(retracted"
488
+ // (skill-review I1). Match the heading line-start (`\n## `) so body text
489
+ // containing `##` can't be mistaken for the heading. (Was `### ` — a
490
+ // pre-existing bug: the writer emits `## `, so this never matched and EVERY
491
+ // decision read `retracted:false` — Task 164.3.)
492
+ // The heading is a line-start `## ` (the block opens with the marker comment,
493
+ // so the heading is never at block offset 0 — match `\n## `).
494
+ const headingNl = block.indexOf('\n## ');
488
495
  const afterHeading =
489
- headingIdx === -1 ? '' : block.slice(block.indexOf('\n', headingIdx) + 1);
496
+ headingNl === -1 ? '' : block.slice(block.indexOf('\n', headingNl + 1) + 1);
490
497
  const retracted = afterHeading.startsWith('_(retracted');
491
498
  hits.push({
492
499
  id: markers[i].id,
@@ -28,7 +28,7 @@
28
28
  // ≤1500-char chunking rule is satisfied by construction).
29
29
 
30
30
  import { createHash } from 'node:crypto';
31
- import { existsSync, readFileSync } from 'node:fs';
31
+ import { parseJsonFile } from './read-json.mjs';
32
32
  import { join } from 'node:path';
33
33
 
34
34
  // The D-105 ladder's WINNER (bake-off 2026-06-10, bench:recall on the Task-99
@@ -383,14 +383,12 @@ const VALID_DEFAULT_MODES = new Set(['keyword', 'semantic', 'hybrid']);
383
383
  * default — no surprise model downloads on machines that never opted in).
384
384
  */
385
385
  export function resolveDefaultSearchMode({ projectRoot }) {
386
- try {
387
- const p = join(projectRoot, 'context', 'settings.json');
388
- if (!existsSync(p)) return 'keyword';
389
- const mode = JSON.parse(readFileSync(p, 'utf8'))?.search?.default_mode;
390
- return VALID_DEFAULT_MODES.has(mode) ? mode : 'keyword';
391
- } catch {
392
- return 'keyword';
393
- }
386
+ // BOM-tolerant (parseJsonFile): a Windows-editor BOM on context/settings.json
387
+ // must not silently downgrade a `hybrid` user to keyword (D-187). Missing or
388
+ // malformed keyword.
389
+ const p = join(projectRoot, 'context', 'settings.json');
390
+ const mode = parseJsonFile(p, { fallback: null })?.search?.default_mode;
391
+ return VALID_DEFAULT_MODES.has(mode) ? mode : 'keyword';
394
392
  }
395
393
 
396
394
  /**
@@ -57,6 +57,7 @@ import {
57
57
  writeFileSync,
58
58
  } from 'node:fs';
59
59
  import { dirname, join } from 'node:path';
60
+ import { stripBom } from './read-json.mjs';
60
61
 
61
62
  /**
62
63
  * Canonical npm-route hooks block. Shell form (no `args`), PATH-resolved
@@ -64,6 +65,10 @@ import { dirname, join } from 'node:path';
64
65
  * (modulo command form) plugin/hooks/hooks.json.
65
66
  */
66
67
  export const KIT_HOOKS_BLOCK = Object.freeze({
68
+ // PreToolUse — the memory delete-guardrail (D-192). Blocks a destructive
69
+ // shell command (rm / Remove-Item / git clean …) aimed at a memory path
70
+ // BEFORE it runs. The only kit hook that can exit non-zero (2 = block).
71
+ PreToolUse: [{ matcher: 'Bash|PowerShell', hooks: [{ type: 'command', command: 'cmk-guard-memory', timeout: 5 }] }],
67
72
  SessionStart: [{ hooks: [{ type: 'command', command: 'cmk-inject-context', timeout: 30 }] }],
68
73
  UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'cmk-capture-prompt', timeout: 10 }] }],
69
74
  PostToolUse: [{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: 'cmk-observe-edit', async: true, timeout: 120 }] }],
@@ -81,6 +86,7 @@ export const KIT_HOOKS_BLOCK = Object.freeze({
81
86
  */
82
87
  export const KIT_COMMAND_TOKENS = Object.freeze([
83
88
  'cmk-version-check',
89
+ 'cmk-guard-memory',
84
90
  'cmk-inject-context',
85
91
  'cmk-capture-prompt',
86
92
  'cmk-observe-edit',
@@ -122,7 +128,9 @@ export function writeKitHooks(settingsPath) {
122
128
  let settings = {};
123
129
  if (existsSync(settingsPath)) {
124
130
  try {
125
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
131
+ // stripBom: a Windows-editor BOM must not make a valid settings.json read
132
+ // as corrupt and block hook wiring (D-187). Real corruption still errors.
133
+ settings = JSON.parse(stripBom(readFileSync(settingsPath, 'utf8')));
126
134
  } catch (err) {
127
135
  return {
128
136
  changed: false,
@@ -250,7 +258,9 @@ export function writeKitMcpServer(projectRoot) {
250
258
  let config = {};
251
259
  if (existsSync(mcpPath)) {
252
260
  try {
253
- config = JSON.parse(readFileSync(mcpPath, 'utf8'));
261
+ // stripBom: a BOM'd .mcp.json must not read as corrupt and block MCP
262
+ // registration (D-187). Real corruption still errors.
263
+ config = JSON.parse(stripBom(readFileSync(mcpPath, 'utf8')));
254
264
  } catch (err) {
255
265
  return { changed: false, path: mcpPath, error: `${mcpPath} parse error: ${err?.message ?? err}` };
256
266
  }