@lh8ppl/claude-memory-kit 0.1.0 → 0.1.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.
- package/README.md +77 -0
- package/bin/cmk-auto-extract.mjs +62 -0
- package/bin/cmk-capture-prompt.mjs +65 -0
- package/bin/cmk-capture-turn.mjs +76 -0
- package/bin/cmk-compress-lazy.mjs +0 -0
- package/bin/cmk-compress-session.mjs +64 -0
- package/bin/cmk-daily-distill.mjs +0 -0
- package/bin/cmk-inject-context.mjs +69 -0
- package/bin/cmk-observe-edit.mjs +57 -0
- package/bin/cmk-weekly-curate.mjs +0 -0
- package/bin/cmk.mjs +11 -11
- package/package.json +10 -2
- package/src/audit-log.mjs +1 -0
- package/src/claude-md.mjs +212 -212
- package/src/doctor.mjs +16 -5
- package/src/frontmatter.mjs +73 -73
- package/src/install.mjs +49 -1
- package/src/merge-facts.mjs +213 -213
- package/src/provenance.mjs +217 -217
- package/src/reindex.mjs +134 -134
- package/src/repair.mjs +26 -96
- package/src/settings-hooks.mjs +186 -0
- package/src/subcommands.mjs +13 -2
- package/template/.gitignore.fragment +12 -12
- package/template/CLAUDE.md.template +49 -49
- package/template/docs/journey/journey-log.md.template +292 -292
- package/template/project/memory/INDEX.md.template +47 -47
- package/template/support/cron-jobs/daily-memory-distill.md +15 -15
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -17
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -15
- package/template/support/milvus-deploy/README.md +57 -57
- package/template/support/milvus-deploy/docker-compose.yml +66 -66
- package/template/support/scripts/auto-extract-memory.sh +102 -102
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -59
- package/template/support/scripts/refresh-distill-timestamp.py +35 -35
- package/template/support/scripts/register-crons.py +242 -242
- package/template/support/scripts/run-daily-distill.sh +67 -67
- package/template/support/scripts/run-weekly-curate.sh +58 -58
package/src/reindex.mjs
CHANGED
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
// Granular-archive pointer-index writer (Task 8, refactored in
|
|
2
|
-
// cleanup-layer-2-cross-module-drift). Single public boundary:
|
|
3
|
-
// reindex(opts) → result. See design §2.3.
|
|
4
|
-
//
|
|
5
|
-
// Uses shared modules: tier-paths (path resolution), frontmatter (js-yaml
|
|
6
|
-
// parse). See CLAUDE.md "Shared modules" rule.
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
existsSync,
|
|
10
|
-
mkdirSync,
|
|
11
|
-
readdirSync,
|
|
12
|
-
readFileSync,
|
|
13
|
-
writeFileSync,
|
|
14
|
-
} from 'node:fs';
|
|
15
|
-
import { join } from 'node:path';
|
|
16
|
-
import { VALID_TIERS, resolveTierRoot, resolveFactDir } from './tier-paths.mjs';
|
|
17
|
-
import { parse } from './frontmatter.mjs';
|
|
18
|
-
|
|
19
|
-
const INDEX_SIZE_WARN_BYTES = 25 * 1024;
|
|
20
|
-
const HOOK_MAX_LEN = 80;
|
|
21
|
-
|
|
22
|
-
const TIER_LABEL = {
|
|
23
|
-
P: 'project tier',
|
|
24
|
-
L: 'local tier',
|
|
25
|
-
U: 'user tier',
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
function extractHook(body) {
|
|
29
|
-
for (const raw of body.split('\n')) {
|
|
30
|
-
const line = raw.trim();
|
|
31
|
-
if (!line) continue;
|
|
32
|
-
if (line.startsWith('#')) continue;
|
|
33
|
-
if (line.length > HOOK_MAX_LEN) {
|
|
34
|
-
return line.slice(0, HOOK_MAX_LEN).trimEnd() + '...';
|
|
35
|
-
}
|
|
36
|
-
return line;
|
|
37
|
-
}
|
|
38
|
-
return '';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function formatIndexLine({ id, type, title, filename, hook }) {
|
|
42
|
-
const head = `- (${id}) [${type}] [${title}](${filename})`;
|
|
43
|
-
return hook ? `${head} — ${hook}` : head;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function listFactFiles(factDir) {
|
|
47
|
-
if (!existsSync(factDir)) return [];
|
|
48
|
-
const out = [];
|
|
49
|
-
for (const entry of readdirSync(factDir, { withFileTypes: true })) {
|
|
50
|
-
if (!entry.isFile()) continue;
|
|
51
|
-
if (!entry.name.endsWith('.md')) continue;
|
|
52
|
-
if (entry.name === 'INDEX.md') continue;
|
|
53
|
-
out.push(entry.name);
|
|
54
|
-
}
|
|
55
|
-
return out.sort();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function reindex(opts = {}) {
|
|
59
|
-
const { tier, projectRoot, userDir, warn } = opts;
|
|
60
|
-
if (!tier || !VALID_TIERS.has(tier)) {
|
|
61
|
-
throw new Error(
|
|
62
|
-
`reindex: invalid tier ${JSON.stringify(tier)}. Must be 'U', 'P', or 'L'.`,
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
const emit = warn ?? ((msg) => process.stderr.write(msg + '\n'));
|
|
66
|
-
const warnings = [];
|
|
67
|
-
function pushWarning(msg) {
|
|
68
|
-
warnings.push(msg);
|
|
69
|
-
emit(msg);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
73
|
-
const factDir = resolveFactDir(tier, tierRoot);
|
|
74
|
-
mkdirSync(factDir, { recursive: true });
|
|
75
|
-
|
|
76
|
-
const entries = [];
|
|
77
|
-
for (const filename of listFactFiles(factDir)) {
|
|
78
|
-
const path = join(factDir, filename);
|
|
79
|
-
let text;
|
|
80
|
-
try {
|
|
81
|
-
text = readFileSync(path, 'utf8');
|
|
82
|
-
} catch (e) {
|
|
83
|
-
pushWarning(`reindex: failed to read ${filename}: ${e.message}`);
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const { frontmatter, body, parseError } = parse(text);
|
|
87
|
-
if (!frontmatter) {
|
|
88
|
-
pushWarning(
|
|
89
|
-
`reindex: ${filename} skipped — ${parseError ?? 'no YAML frontmatter'}`,
|
|
90
|
-
);
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (!frontmatter.id || !frontmatter.type || !frontmatter.title) {
|
|
94
|
-
pushWarning(
|
|
95
|
-
`reindex: ${filename} skipped — missing required frontmatter field(s) (id/type/title)`,
|
|
96
|
-
);
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (frontmatter.deleted_at) continue;
|
|
100
|
-
entries.push({
|
|
101
|
-
id: frontmatter.id,
|
|
102
|
-
type: frontmatter.type,
|
|
103
|
-
title: frontmatter.title,
|
|
104
|
-
filename,
|
|
105
|
-
hook: extractHook(body),
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
110
|
-
|
|
111
|
-
const header = `# Granular memory index — ${TIER_LABEL[tier]}\n\n## Files\n`;
|
|
112
|
-
const bodyLines = entries.map(formatIndexLine).join('\n');
|
|
113
|
-
const content = entries.length
|
|
114
|
-
? `${header}\n${bodyLines}\n`
|
|
115
|
-
: `${header}\n`;
|
|
116
|
-
|
|
117
|
-
const indexPath = join(factDir, 'INDEX.md');
|
|
118
|
-
writeFileSync(indexPath, content, 'utf8');
|
|
119
|
-
|
|
120
|
-
const bytes = Buffer.byteLength(content, 'utf8');
|
|
121
|
-
if (bytes > INDEX_SIZE_WARN_BYTES) {
|
|
122
|
-
pushWarning(
|
|
123
|
-
`reindex: ${indexPath} is ${(bytes / 1024).toFixed(1)} KB (>25 KB); consider consolidation`,
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
tier,
|
|
129
|
-
indexPath,
|
|
130
|
-
factCount: entries.length,
|
|
131
|
-
bytes,
|
|
132
|
-
warnings,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
1
|
+
// Granular-archive pointer-index writer (Task 8, refactored in
|
|
2
|
+
// cleanup-layer-2-cross-module-drift). Single public boundary:
|
|
3
|
+
// reindex(opts) → result. See design §2.3.
|
|
4
|
+
//
|
|
5
|
+
// Uses shared modules: tier-paths (path resolution), frontmatter (js-yaml
|
|
6
|
+
// parse). See CLAUDE.md "Shared modules" rule.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { VALID_TIERS, resolveTierRoot, resolveFactDir } from './tier-paths.mjs';
|
|
17
|
+
import { parse } from './frontmatter.mjs';
|
|
18
|
+
|
|
19
|
+
const INDEX_SIZE_WARN_BYTES = 25 * 1024;
|
|
20
|
+
const HOOK_MAX_LEN = 80;
|
|
21
|
+
|
|
22
|
+
const TIER_LABEL = {
|
|
23
|
+
P: 'project tier',
|
|
24
|
+
L: 'local tier',
|
|
25
|
+
U: 'user tier',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function extractHook(body) {
|
|
29
|
+
for (const raw of body.split('\n')) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line) continue;
|
|
32
|
+
if (line.startsWith('#')) continue;
|
|
33
|
+
if (line.length > HOOK_MAX_LEN) {
|
|
34
|
+
return line.slice(0, HOOK_MAX_LEN).trimEnd() + '...';
|
|
35
|
+
}
|
|
36
|
+
return line;
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatIndexLine({ id, type, title, filename, hook }) {
|
|
42
|
+
const head = `- (${id}) [${type}] [${title}](${filename})`;
|
|
43
|
+
return hook ? `${head} — ${hook}` : head;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function listFactFiles(factDir) {
|
|
47
|
+
if (!existsSync(factDir)) return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const entry of readdirSync(factDir, { withFileTypes: true })) {
|
|
50
|
+
if (!entry.isFile()) continue;
|
|
51
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
52
|
+
if (entry.name === 'INDEX.md') continue;
|
|
53
|
+
out.push(entry.name);
|
|
54
|
+
}
|
|
55
|
+
return out.sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function reindex(opts = {}) {
|
|
59
|
+
const { tier, projectRoot, userDir, warn } = opts;
|
|
60
|
+
if (!tier || !VALID_TIERS.has(tier)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`reindex: invalid tier ${JSON.stringify(tier)}. Must be 'U', 'P', or 'L'.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const emit = warn ?? ((msg) => process.stderr.write(msg + '\n'));
|
|
66
|
+
const warnings = [];
|
|
67
|
+
function pushWarning(msg) {
|
|
68
|
+
warnings.push(msg);
|
|
69
|
+
emit(msg);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
|
|
73
|
+
const factDir = resolveFactDir(tier, tierRoot);
|
|
74
|
+
mkdirSync(factDir, { recursive: true });
|
|
75
|
+
|
|
76
|
+
const entries = [];
|
|
77
|
+
for (const filename of listFactFiles(factDir)) {
|
|
78
|
+
const path = join(factDir, filename);
|
|
79
|
+
let text;
|
|
80
|
+
try {
|
|
81
|
+
text = readFileSync(path, 'utf8');
|
|
82
|
+
} catch (e) {
|
|
83
|
+
pushWarning(`reindex: failed to read ${filename}: ${e.message}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const { frontmatter, body, parseError } = parse(text);
|
|
87
|
+
if (!frontmatter) {
|
|
88
|
+
pushWarning(
|
|
89
|
+
`reindex: ${filename} skipped — ${parseError ?? 'no YAML frontmatter'}`,
|
|
90
|
+
);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!frontmatter.id || !frontmatter.type || !frontmatter.title) {
|
|
94
|
+
pushWarning(
|
|
95
|
+
`reindex: ${filename} skipped — missing required frontmatter field(s) (id/type/title)`,
|
|
96
|
+
);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (frontmatter.deleted_at) continue;
|
|
100
|
+
entries.push({
|
|
101
|
+
id: frontmatter.id,
|
|
102
|
+
type: frontmatter.type,
|
|
103
|
+
title: frontmatter.title,
|
|
104
|
+
filename,
|
|
105
|
+
hook: extractHook(body),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
110
|
+
|
|
111
|
+
const header = `# Granular memory index — ${TIER_LABEL[tier]}\n\n## Files\n`;
|
|
112
|
+
const bodyLines = entries.map(formatIndexLine).join('\n');
|
|
113
|
+
const content = entries.length
|
|
114
|
+
? `${header}\n${bodyLines}\n`
|
|
115
|
+
: `${header}\n`;
|
|
116
|
+
|
|
117
|
+
const indexPath = join(factDir, 'INDEX.md');
|
|
118
|
+
writeFileSync(indexPath, content, 'utf8');
|
|
119
|
+
|
|
120
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
121
|
+
if (bytes > INDEX_SIZE_WARN_BYTES) {
|
|
122
|
+
pushWarning(
|
|
123
|
+
`reindex: ${indexPath} is ${(bytes / 1024).toFixed(1)} KB (>25 KB); consider consolidation`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
tier,
|
|
129
|
+
indexPath,
|
|
130
|
+
factCount: entries.length,
|
|
131
|
+
bytes,
|
|
132
|
+
warnings,
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/repair.mjs
CHANGED
|
@@ -16,14 +16,10 @@
|
|
|
16
16
|
// Per design §14 + tasks.md 39 (39.1–39.3).
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
|
-
existsSync,
|
|
20
|
-
mkdirSync,
|
|
21
|
-
readFileSync,
|
|
22
19
|
statSync,
|
|
23
20
|
unlinkSync,
|
|
24
|
-
writeFileSync,
|
|
25
21
|
} from 'node:fs';
|
|
26
|
-
import {
|
|
22
|
+
import { join } from 'node:path';
|
|
27
23
|
import {
|
|
28
24
|
appendAuditEntry,
|
|
29
25
|
nowIso,
|
|
@@ -31,104 +27,38 @@ import {
|
|
|
31
27
|
} from './audit-log.mjs';
|
|
32
28
|
import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
|
|
33
29
|
import { detectStaleLocks } from './lock-discipline.mjs';
|
|
30
|
+
import { writeKitHooks } from './settings-hooks.mjs';
|
|
34
31
|
|
|
35
32
|
const DEFAULT_STALE_LOCK_MS = 60 * 60 * 1000; // 1 hour
|
|
36
33
|
const SETTINGS_REL = ['.claude', 'settings.json'];
|
|
37
34
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
35
|
+
// Task 49 (2026-05-29): the canonical hooks block + the read-merge-write
|
|
36
|
+
// logic moved to settings-hooks.mjs (single source of truth, shared with
|
|
37
|
+
// install.mjs). It also switched from the PLUGIN form (`bash
|
|
38
|
+
// "${CLAUDE_PLUGIN_ROOT}/bin/<name>"`, 6 events incl. Setup) to the
|
|
39
|
+
// npm-route form (PATH-resolved bare bin names, 5 events) so that
|
|
40
|
+
// `cmk repair --hooks` produces hooks that work with NO plugin loaded —
|
|
41
|
+
// matching `cmk install`'s new posture as a complete entry point. The
|
|
42
|
+
// full decision trail (incl. why Setup/cmk-version-check is dropped and
|
|
43
|
+
// where the plugin form still lives) is documented in settings-hooks.mjs.
|
|
45
44
|
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// future validator (scripts/validate-hooks-block-sync.mjs) would catch
|
|
52
|
-
// drift automatically; v0.1.x candidate.
|
|
53
|
-
const KIT_HOOKS_BLOCK = Object.freeze({
|
|
54
|
-
Setup: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-version-check"', timeout: 30 }] }],
|
|
55
|
-
SessionStart: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-inject-context"', timeout: 30 }] }],
|
|
56
|
-
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-capture-prompt"', timeout: 10 }] }],
|
|
57
|
-
PostToolUse: [{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-observe-edit"', async: true, timeout: 120 }] }],
|
|
58
|
-
Stop: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-capture-turn"', timeout: 30 }] }],
|
|
59
|
-
SessionEnd: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-compress-session"', timeout: 60 }] }],
|
|
60
|
-
});
|
|
45
|
+
// Pre-Task-49 history (kept for the trail): the block was previously
|
|
46
|
+
// embedded inline here as a fix for the I1 finding (Task 39 skill-review,
|
|
47
|
+
// 2026-05-28) — reading from plugin/hooks/hooks.json broke post-npm-install-g
|
|
48
|
+
// because the plugin/ tree is outside the published tarball. The inline
|
|
49
|
+
// constant fixed that; Task 49 then extracted it to the shared module.
|
|
61
50
|
|
|
62
51
|
/**
|
|
63
52
|
* Repair `<projectRoot>/.claude/settings.json` by merging in the kit's
|
|
64
|
-
* canonical hooks block
|
|
65
|
-
*
|
|
66
|
-
* matchers).
|
|
53
|
+
* canonical hooks block (via the shared writeKitHooks boundary).
|
|
54
|
+
* Preserves any other top-level keys + non-kit hook entries.
|
|
67
55
|
*/
|
|
68
56
|
function repairHooks({ projectRoot, ts }) {
|
|
69
|
-
// I1 fix: use the inlined KIT_HOOKS_BLOCK constant; no file read,
|
|
70
|
-
// no npm-install-g brittleness.
|
|
71
|
-
const kitHooks = KIT_HOOKS_BLOCK;
|
|
72
|
-
|
|
73
57
|
const settingsPath = join(projectRoot, ...SETTINGS_REL);
|
|
74
|
-
|
|
75
|
-
if (existsSync(settingsPath)) {
|
|
76
|
-
try {
|
|
77
|
-
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
78
|
-
} catch (err) {
|
|
79
|
-
return {
|
|
80
|
-
kind: 'hooks',
|
|
81
|
-
changed: false,
|
|
82
|
-
error: `${settingsPath} parse error: ${err?.message ?? err}`,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const before = JSON.stringify(settings);
|
|
88
|
-
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
89
|
-
settings.hooks = {};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Merge each event array: replace the kit's hook entries (matched by
|
|
93
|
-
// command substring) with the canonical version; keep any user-added
|
|
94
|
-
// entries that don't reference the kit.
|
|
95
|
-
const KIT_COMMAND_TOKENS = [
|
|
96
|
-
'cmk-version-check',
|
|
97
|
-
'cmk-inject-context',
|
|
98
|
-
'cmk-capture-prompt',
|
|
99
|
-
'cmk-observe-edit',
|
|
100
|
-
'cmk-capture-turn',
|
|
101
|
-
'cmk-compress-session',
|
|
102
|
-
];
|
|
103
|
-
const isKitEntry = (entry) => {
|
|
104
|
-
if (!entry || typeof entry !== 'object') return false;
|
|
105
|
-
// Entry shape varies: {command} or {hooks: [{command}]}.
|
|
106
|
-
const collectCommands = (e) => {
|
|
107
|
-
const cmds = [];
|
|
108
|
-
if (typeof e.command === 'string') cmds.push(e.command);
|
|
109
|
-
if (Array.isArray(e.hooks)) {
|
|
110
|
-
for (const h of e.hooks) if (typeof h.command === 'string') cmds.push(h.command);
|
|
111
|
-
}
|
|
112
|
-
return cmds;
|
|
113
|
-
};
|
|
114
|
-
const cmds = collectCommands(entry);
|
|
115
|
-
return cmds.some((c) => KIT_COMMAND_TOKENS.some((t) => c.includes(t)));
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
for (const [eventName, kitEntries] of Object.entries(kitHooks)) {
|
|
119
|
-
const existing = Array.isArray(settings.hooks[eventName])
|
|
120
|
-
? settings.hooks[eventName]
|
|
121
|
-
: [];
|
|
122
|
-
const userEntries = existing.filter((e) => !isKitEntry(e));
|
|
123
|
-
settings.hooks[eventName] = [...userEntries, ...kitEntries];
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const after = JSON.stringify(settings);
|
|
127
|
-
const changed = before !== after;
|
|
58
|
+
const r = writeKitHooks(settingsPath);
|
|
128
59
|
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
60
|
+
if (r.error) {
|
|
61
|
+
return { kind: 'hooks', changed: false, error: r.error };
|
|
132
62
|
}
|
|
133
63
|
|
|
134
64
|
// I3 fix (Task 39 skill-review 2026-05-28): emit a Door-4 audit
|
|
@@ -141,8 +71,8 @@ function repairHooks({ projectRoot, ts }) {
|
|
|
141
71
|
action: 'repair',
|
|
142
72
|
tier: 'P',
|
|
143
73
|
id: 'P-RPHKAPLD', // synthetic stable id for hooks-repair events (base32 alphabet)
|
|
144
|
-
reasonCode: changed ? REASON_CODES.REPAIR_HOOKS_APPLIED : REASON_CODES.REPAIR_HOOKS_NOOP,
|
|
145
|
-
extra: { settingsPath, events:
|
|
74
|
+
reasonCode: r.changed ? REASON_CODES.REPAIR_HOOKS_APPLIED : REASON_CODES.REPAIR_HOOKS_NOOP,
|
|
75
|
+
extra: { settingsPath: r.settingsPath, events: r.events },
|
|
146
76
|
});
|
|
147
77
|
} catch {
|
|
148
78
|
// best-effort — never block repair on audit-log failure
|
|
@@ -150,9 +80,9 @@ function repairHooks({ projectRoot, ts }) {
|
|
|
150
80
|
|
|
151
81
|
return {
|
|
152
82
|
kind: 'hooks',
|
|
153
|
-
changed,
|
|
154
|
-
settingsPath,
|
|
155
|
-
events:
|
|
83
|
+
changed: r.changed,
|
|
84
|
+
settingsPath: r.settingsPath,
|
|
85
|
+
events: r.events,
|
|
156
86
|
};
|
|
157
87
|
}
|
|
158
88
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// settings-hooks.mjs — the canonical npm-route hooks block + the
|
|
2
|
+
// read-merge-write logic that wires it into a project's
|
|
3
|
+
// <projectRoot>/.claude/settings.json. (Task 49, T-037.)
|
|
4
|
+
//
|
|
5
|
+
// SINGLE SOURCE OF TRUTH for the npm-route hook wiring. Both
|
|
6
|
+
// `cmk install` (install.mjs — the complete npm entry point) and
|
|
7
|
+
// `cmk repair --hooks` (repair.mjs) import from here. Before Task 49
|
|
8
|
+
// the block lived inline in repair.mjs as `KIT_HOOKS_BLOCK`; it was
|
|
9
|
+
// extracted + de-plugin-ified here so install can share it.
|
|
10
|
+
//
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
12
|
+
// Command form: SHELL form (no `args`), bare bin name. WHY (verified
|
|
13
|
+
// against Anthropic's hooks docs, https://code.claude.com/docs/en/hooks,
|
|
14
|
+
// 2026-05-29):
|
|
15
|
+
//
|
|
16
|
+
// - Hook commands with `args` present run in EXEC form (no shell). On
|
|
17
|
+
// Windows, exec form requires `command` to resolve to a REAL
|
|
18
|
+
// executable (.exe) — the docs explicitly warn that the `.cmd`/`.ps1`
|
|
19
|
+
// shims npm installs for `bin` entries "cannot be spawned without a
|
|
20
|
+
// shell". So exec form + a bare `cmk-inject-context` would FAIL on
|
|
21
|
+
// Windows.
|
|
22
|
+
// - Hook commands WITHOUT `args` run in SHELL form: `sh -c "<command>"`
|
|
23
|
+
// on macOS/Linux, Git Bash (or PowerShell) on Windows. The shell
|
|
24
|
+
// resolves the bare name on PATH — picking up npm's global shim on
|
|
25
|
+
// every OS. So we deliberately OMIT `args` and emit a bare bin name.
|
|
26
|
+
//
|
|
27
|
+
// This is why the block below has no `args` and a bare command string.
|
|
28
|
+
// `npm install -g @lh8ppl/claude-memory-kit` puts these 5 bins on PATH
|
|
29
|
+
// (declared in packages/cli/package.json `bin`); the hook commands then
|
|
30
|
+
// resolve the same way `cmk` itself does.
|
|
31
|
+
//
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Decision trail (CLAUDE.md "Decision-trail preservation"):
|
|
34
|
+
//
|
|
35
|
+
// **Original block (pre-2026-05-29, repair.mjs)**: the PLUGIN form,
|
|
36
|
+
// `bash "${CLAUDE_PLUGIN_ROOT}/bin/<name>"`, 6 events incl. Setup →
|
|
37
|
+
// cmk-version-check. That form is correct ONLY when the plugin is
|
|
38
|
+
// loaded (CLAUDE_PLUGIN_ROOT is set + bash is present). It still lives
|
|
39
|
+
// in plugin/hooks/hooks.json for the PLUGIN route (Route B), unchanged.
|
|
40
|
+
//
|
|
41
|
+
// **Task 49 (2026-05-29)**: the npm route (Route A) needs hooks that
|
|
42
|
+
// work with NO plugin loaded. This block is that form. It drops the
|
|
43
|
+
// Setup → cmk-version-check hook: version-check is a not-yet-implemented
|
|
44
|
+
// bash stub (no node bin ships for it — see tasks.md 49.1, which lists
|
|
45
|
+
// exactly the 5 functional hooks), and `Setup` is not in Anthropic's
|
|
46
|
+
// documented common-events set. Porting version-check to a node bin is
|
|
47
|
+
// a v0.1.x item if a real Setup-time check is wanted. The 5 functional
|
|
48
|
+
// hooks below are the complete auto-memory loop.
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
existsSync,
|
|
52
|
+
mkdirSync,
|
|
53
|
+
readFileSync,
|
|
54
|
+
writeFileSync,
|
|
55
|
+
} from 'node:fs';
|
|
56
|
+
import { dirname } from 'node:path';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Canonical npm-route hooks block. Shell form (no `args`), PATH-resolved
|
|
60
|
+
* bare bin names. Keep in sync with packages/cli/package.json `bin` and
|
|
61
|
+
* (modulo command form) plugin/hooks/hooks.json.
|
|
62
|
+
*/
|
|
63
|
+
export const KIT_HOOKS_BLOCK = Object.freeze({
|
|
64
|
+
SessionStart: [{ hooks: [{ type: 'command', command: 'cmk-inject-context', timeout: 30 }] }],
|
|
65
|
+
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'cmk-capture-prompt', timeout: 10 }] }],
|
|
66
|
+
PostToolUse: [{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: 'cmk-observe-edit', async: true, timeout: 120 }] }],
|
|
67
|
+
Stop: [{ hooks: [{ type: 'command', command: 'cmk-capture-turn', timeout: 30 }] }],
|
|
68
|
+
SessionEnd: [{ hooks: [{ type: 'command', command: 'cmk-compress-session', timeout: 60 }] }],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Substrings that identify a kit-owned hook entry, so re-runs replace the
|
|
73
|
+
* kit's entries in place while preserving the user's own hooks. Covers
|
|
74
|
+
* BOTH command forms (npm-route bare names AND the plugin-route
|
|
75
|
+
* `${CLAUDE_PLUGIN_ROOT}/bin/<name>` form) so a project that previously
|
|
76
|
+
* had plugin-form kit hooks gets them cleanly upgraded to npm-form rather
|
|
77
|
+
* than duplicated.
|
|
78
|
+
*/
|
|
79
|
+
export const KIT_COMMAND_TOKENS = Object.freeze([
|
|
80
|
+
'cmk-version-check',
|
|
81
|
+
'cmk-inject-context',
|
|
82
|
+
'cmk-capture-prompt',
|
|
83
|
+
'cmk-observe-edit',
|
|
84
|
+
'cmk-capture-turn',
|
|
85
|
+
'cmk-compress-session',
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/** True if a hooks-array entry references any kit bin. */
|
|
89
|
+
function isKitEntry(entry) {
|
|
90
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
91
|
+
const cmds = [];
|
|
92
|
+
if (typeof entry.command === 'string') cmds.push(entry.command);
|
|
93
|
+
if (Array.isArray(entry.hooks)) {
|
|
94
|
+
for (const h of entry.hooks) if (typeof h.command === 'string') cmds.push(h.command);
|
|
95
|
+
}
|
|
96
|
+
return cmds.some((c) => KIT_COMMAND_TOKENS.some((t) => c.includes(t)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read-merge-write the canonical kit hooks block into the settings.json
|
|
101
|
+
* at `settingsPath`. Idempotent. Preserves any non-kit top-level keys and
|
|
102
|
+
* any non-kit hook entries under the same events.
|
|
103
|
+
*
|
|
104
|
+
* Public boundary (install.mjs + repair.mjs depend on this shape):
|
|
105
|
+
* writeKitHooks(settingsPath) → {
|
|
106
|
+
* changed: boolean, // did the file content change?
|
|
107
|
+
* settingsPath: string,
|
|
108
|
+
* events: string[], // kit events written
|
|
109
|
+
* error?: string, // present iff the existing file was unparseable
|
|
110
|
+
* }
|
|
111
|
+
*
|
|
112
|
+
* On a JSON parse error of an existing settings.json, returns
|
|
113
|
+
* {changed:false, error} WITHOUT overwriting — never clobber a file the
|
|
114
|
+
* user may have hand-broken; surface it so they can fix it.
|
|
115
|
+
*/
|
|
116
|
+
export function writeKitHooks(settingsPath) {
|
|
117
|
+
const events = Object.keys(KIT_HOOKS_BLOCK);
|
|
118
|
+
|
|
119
|
+
let settings = {};
|
|
120
|
+
if (existsSync(settingsPath)) {
|
|
121
|
+
try {
|
|
122
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
changed: false,
|
|
126
|
+
settingsPath,
|
|
127
|
+
events,
|
|
128
|
+
error: `${settingsPath} parse error: ${err?.message ?? err}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const before = JSON.stringify(settings);
|
|
134
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
135
|
+
settings.hooks = {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Walk the UNION of existing events + kit events. For each event, strip
|
|
139
|
+
// any kit-owned entries (matched by KIT_COMMAND_TOKENS), preserving the
|
|
140
|
+
// user's own entries; then re-add the canonical kit entries for events
|
|
141
|
+
// the kit manages. Walking existing events too (not just the 5 kit
|
|
142
|
+
// events) is what PRUNES a stale plugin-form hook the kit no longer
|
|
143
|
+
// emits — e.g. a leftover `Setup → cmk-version-check` written by a
|
|
144
|
+
// pre-0.1.1 `cmk repair --hooks`: it's a kit entry under an event NOT in
|
|
145
|
+
// KIT_HOOKS_BLOCK, so it gets removed rather than left to fail on the
|
|
146
|
+
// npm route (no ${CLAUDE_PLUGIN_ROOT}, no bash).
|
|
147
|
+
const allEvents = new Set([
|
|
148
|
+
...Object.keys(settings.hooks),
|
|
149
|
+
...Object.keys(KIT_HOOKS_BLOCK),
|
|
150
|
+
]);
|
|
151
|
+
for (const eventName of allEvents) {
|
|
152
|
+
const existing = Array.isArray(settings.hooks[eventName])
|
|
153
|
+
? settings.hooks[eventName]
|
|
154
|
+
: [];
|
|
155
|
+
const isKitEvent = Object.prototype.hasOwnProperty.call(KIT_HOOKS_BLOCK, eventName);
|
|
156
|
+
const hadKitEntry = existing.some(isKitEntry);
|
|
157
|
+
// Leave purely-user events untouched (don't even rewrite an empty
|
|
158
|
+
// array the user authored) — only manage events the kit owns OR that
|
|
159
|
+
// currently carry a stale kit entry to prune.
|
|
160
|
+
if (!isKitEvent && !hadKitEntry) continue;
|
|
161
|
+
const userEntries = existing.filter((e) => !isKitEntry(e));
|
|
162
|
+
// Deep-clone the kit entries before inserting: KIT_HOOKS_BLOCK is only
|
|
163
|
+
// shallow-frozen, so inserting its nested objects by reference would let
|
|
164
|
+
// a later mutation of `settings` leak back into the shared constant.
|
|
165
|
+
const kitEntries = isKitEvent ? structuredClone(KIT_HOOKS_BLOCK[eventName]) : [];
|
|
166
|
+
const next = [...userEntries, ...kitEntries];
|
|
167
|
+
if (next.length > 0) {
|
|
168
|
+
settings.hooks[eventName] = next;
|
|
169
|
+
} else {
|
|
170
|
+
// Event held only kit entries the kit no longer emits (e.g. a stale
|
|
171
|
+
// plugin-form Setup → cmk-version-check): drop the now-empty array
|
|
172
|
+
// instead of leaving `"Setup": []` behind.
|
|
173
|
+
delete settings.hooks[eventName];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const after = JSON.stringify(settings);
|
|
178
|
+
const changed = before !== after;
|
|
179
|
+
|
|
180
|
+
if (changed) {
|
|
181
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
182
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { changed, settingsPath, events };
|
|
186
|
+
}
|
package/src/subcommands.mjs
CHANGED
|
@@ -62,15 +62,25 @@ const NOTICE_PREFIX = 'not yet implemented in v0.1.0';
|
|
|
62
62
|
* replaced / upgraded / downgrade-blocked / forced-downgrade / unchanged).
|
|
63
63
|
*/
|
|
64
64
|
async function runInstall(options /* , command */) {
|
|
65
|
-
|
|
65
|
+
// commander maps `--no-hooks` to options.hooks === false.
|
|
66
|
+
const noHooks = !!(options && options.hooks === false);
|
|
67
|
+
const result = await installAction({ force: !!(options && options.force), noHooks });
|
|
66
68
|
const parts = [
|
|
67
69
|
`scaffolded ${result.created.length} file(s)`,
|
|
68
70
|
result.skipped.length ? `skipped ${result.skipped.length} existing` : null,
|
|
69
71
|
`.gitignore=${result.gitignore.action}`,
|
|
70
72
|
`CLAUDE.md=${result.claudeMd.action}`,
|
|
73
|
+
`hooks=${result.hooks.action}`,
|
|
71
74
|
].filter(Boolean);
|
|
72
75
|
console.log('cmk install: ' + parts.join(', '));
|
|
73
76
|
|
|
77
|
+
if (result.hooks.action === 'wired' || result.hooks.action === 'unchanged') {
|
|
78
|
+
console.log(
|
|
79
|
+
' hooks wired into .claude/settings.json — restart Claude Code to activate. ' +
|
|
80
|
+
'This is a COMPLETE install; no separate /plugin step is needed.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
if (result.claudeMd.action === 'downgrade-blocked') {
|
|
75
85
|
console.error(
|
|
76
86
|
` warning: CLAUDE.md already has a newer kit block (v${result.claudeMd.oldVersion}). ` +
|
|
@@ -1007,10 +1017,11 @@ function stub(name, milestone, extra) {
|
|
|
1007
1017
|
export const subcommands = [
|
|
1008
1018
|
{
|
|
1009
1019
|
name: 'install',
|
|
1010
|
-
description: 'cross-OS one-shot install — scaffold 3-tier dirs + inject .gitignore + drop kit CLAUDE.md block',
|
|
1020
|
+
description: 'cross-OS one-shot install — scaffold 3-tier dirs + inject .gitignore + drop kit CLAUDE.md block + wire Claude Code hooks',
|
|
1011
1021
|
milestone: 3,
|
|
1012
1022
|
optionSpec: [
|
|
1013
1023
|
{ flags: '--force', description: 'allow downgrade of an existing newer-version CLAUDE.md block' },
|
|
1024
|
+
{ flags: '--no-hooks', description: 'scaffold only; do NOT wire hooks into .claude/settings.json' },
|
|
1014
1025
|
],
|
|
1015
1026
|
action: runInstall,
|
|
1016
1027
|
},
|