@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.
- package/README.md +6 -0
- package/bin/cmk-guard-memory.mjs +57 -0
- package/package.json +3 -2
- package/src/agent-profile.mjs +115 -0
- package/src/agent-profiles.mjs +118 -0
- package/src/auto-persona.mjs +4 -1
- package/src/compress-retry.mjs +25 -0
- package/src/compress-session.mjs +27 -3
- package/src/config-core.mjs +7 -9
- package/src/daily-distill.mjs +7 -3
- package/src/decisions-journal.mjs +71 -3
- package/src/doctor.mjs +86 -4
- package/src/guard-memory.mjs +151 -0
- package/src/import-anthropic-memory.mjs +15 -1
- package/src/inject-context.mjs +34 -3
- package/src/install-agent.mjs +220 -0
- package/src/install-kiro.mjs +287 -0
- package/src/install.mjs +16 -3
- package/src/kiro-cli-agent.mjs +270 -0
- package/src/kiro-constants.mjs +19 -0
- package/src/kiro-hook-bin.mjs +105 -0
- package/src/kiro-hook-command.mjs +67 -0
- package/src/kiro-hook-dispatch.mjs +115 -0
- package/src/kiro-ide-hooks.mjs +219 -0
- package/src/kiro-permissions.mjs +175 -0
- package/src/kiro-skills.mjs +96 -0
- package/src/kiro-transcript.mjs +366 -0
- package/src/kiro-trusted-commands.mjs +130 -0
- package/src/lazy-compress.mjs +6 -0
- package/src/managed-block.mjs +138 -0
- package/src/memory-write.mjs +23 -8
- package/src/mutate-agent-config.mjs +243 -0
- package/src/read-json.mjs +43 -0
- package/src/reindex.mjs +15 -2
- package/src/repair.mjs +39 -3
- package/src/result-shapes.mjs +8 -0
- package/src/review-queue.mjs +3 -0
- package/src/scratchpad.mjs +12 -2
- package/src/search.mjs +12 -5
- package/src/semantic-backend.mjs +7 -9
- package/src/settings-hooks.mjs +12 -2
- package/src/subcommands.mjs +360 -27
- package/src/tier-paths.mjs +48 -1
- package/src/weekly-curate.mjs +13 -6
- package/template/.claude/skills/memory-search/SKILL.md +14 -1
- package/template/.claude/skills/memory-write/SKILL.md +37 -1
- package/template/project/memory/INDEX.md.template +1 -1
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
|
-
//
|
|
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
|
-
|
|
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 });
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// guard-memory.mjs — the memory delete-guardrail core (D-192).
|
|
2
|
+
//
|
|
3
|
+
// A Claude Code `PreToolUse` hook (wired by `cmk install`) calls the
|
|
4
|
+
// `cmk-guard-memory` bin before every Bash/PowerShell tool call. This module
|
|
5
|
+
// is the pure decision: given a shell command, should it be BLOCKED because it
|
|
6
|
+
// would delete a claude-memory-kit memory path?
|
|
7
|
+
//
|
|
8
|
+
// Why this exists: 2026-06-22 (D-192), a `cd` that silently failed left a
|
|
9
|
+
// following `rm -f context/sessions/* context/transcripts/*` running in the
|
|
10
|
+
// wrong repo, deleting gitignored (non-recoverable) memory. A prose rule relies
|
|
11
|
+
// on the agent remembering; this hook enforces it structurally — the command
|
|
12
|
+
// never runs. Recovered only via an off-machine backup; this prevents the next.
|
|
13
|
+
//
|
|
14
|
+
// Design: BROAD by intent. A false block is recoverable (the user rephrases or
|
|
15
|
+
// deletes by hand); a false allow is the data loss we're preventing. So the
|
|
16
|
+
// predicates favor over-blocking a memory delete over under-blocking one.
|
|
17
|
+
|
|
18
|
+
// A command is destructive if it invokes a delete/destroy verb OR an equivalent
|
|
19
|
+
// data-destroying mechanism that carries no `rm` token (find -delete, truncate,
|
|
20
|
+
// a `>`/`:>` redirection that truncates a file). Skill-review I2: a memory file
|
|
21
|
+
// can be destroyed with no `rm` at all.
|
|
22
|
+
const DESTRUCTIVE = [
|
|
23
|
+
/\brm\b/i, // unix rm
|
|
24
|
+
/\bRemove-Item\b/i, // PowerShell
|
|
25
|
+
/\brmdir\b/i,
|
|
26
|
+
/\brd\b/i, // cmd rd
|
|
27
|
+
/\bdel\b/i, // cmd del
|
|
28
|
+
/\bunlink\b/i,
|
|
29
|
+
/\bgit\s+clean\b/i,
|
|
30
|
+
/\bgit\s+reset\s+--hard\b/i,
|
|
31
|
+
/\bfind\b[^|]*-delete\b/i, // find … -delete
|
|
32
|
+
/\btruncate\b/i, // truncate -s0 file
|
|
33
|
+
/(^|[\s;&|])>\s*\S/, // a `>`/`> file` redirection (truncates the target to empty)
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// A path is a MEMORY path if the command mentions any of these. The bare
|
|
37
|
+
// `context` segment matcher requires a path-ish boundary so the WORD "context"
|
|
38
|
+
// (e.g. `grep context`, `rm contextual.md`) is not a false positive.
|
|
39
|
+
const MEMORY_TOKENS = [
|
|
40
|
+
/context\/sessions/i,
|
|
41
|
+
/context\/transcripts/i,
|
|
42
|
+
/context\/memory/i,
|
|
43
|
+
/context\.local/i,
|
|
44
|
+
// a `context` path segment: `context/`, `context\`, `./context`, `repo/context`,
|
|
45
|
+
// or a bare ` context` argument (e.g. `git clean -fd context`).
|
|
46
|
+
/(^|[\s'"./\\])context([\s'"/\\]|$)/i,
|
|
47
|
+
/\.claude-memory-kit/i, // the cross-project user tier
|
|
48
|
+
/MEMORY\.md/i,
|
|
49
|
+
/DECISIONS\.md/i,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** True if the command invokes a delete/destroy verb. */
|
|
53
|
+
export function isDestructive(cmd) {
|
|
54
|
+
return typeof cmd === 'string' && DESTRUCTIVE.some((re) => re.test(cmd));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True if the command references a claude-memory-kit memory path. */
|
|
58
|
+
export function touchesMemory(cmd) {
|
|
59
|
+
return typeof cmd === 'string' && MEMORY_TOKENS.some((re) => re.test(cmd));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A SEGMENT (not the whole command) may be exempt: it only MENTIONS a delete in
|
|
63
|
+
// its text and can't itself execute one — a `git commit` whose message describes
|
|
64
|
+
// a delete, an `echo`/`grep`/`cat` of text about a delete. (oh-my-kiro's
|
|
65
|
+
// block-dangerous.sh exempts `git commit` for the same reason.) CRITICAL: this
|
|
66
|
+
// is applied PER SEGMENT, never to a whole compound command — `echo x && rm -rf
|
|
67
|
+
// context/memory` must NOT be exempted by its leading `echo` (skill-review B1).
|
|
68
|
+
const SEGMENT_EXEMPT = [
|
|
69
|
+
/^\s*git\s+commit\b/i,
|
|
70
|
+
/^\s*git\s+log\b/i,
|
|
71
|
+
/^\s*echo\b/i,
|
|
72
|
+
/^\s*(grep|rg|cat|less|head|tail|sed -n|awk)\b/i,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// Split a shell command into its sequenced segments on the separators that start
|
|
76
|
+
// a NEW command: && || ; | and newlines. (A `>` redirection is NOT a separator —
|
|
77
|
+
// it's handled as a destructive mechanism on its own segment.) Also surface any
|
|
78
|
+
// $(...) / `...` command substitution as its own segment so a delete hidden in a
|
|
79
|
+
// commit-message arg (`git commit -m "$(rm -rf context/memory)"`) is evaluated
|
|
80
|
+
// on its own and can't ride the outer command's exemption (skill-review I1).
|
|
81
|
+
function splitSegments(cmd) {
|
|
82
|
+
const segments = [];
|
|
83
|
+
// pull out command substitutions first, add them as standalone segments
|
|
84
|
+
const subRe = /\$\(([^)]*)\)|`([^`]*)`/g;
|
|
85
|
+
let m;
|
|
86
|
+
while ((m = subRe.exec(cmd)) !== null) {
|
|
87
|
+
const inner = m[1] ?? m[2];
|
|
88
|
+
if (inner && inner.trim()) segments.push(inner);
|
|
89
|
+
}
|
|
90
|
+
const stripped = cmd.replace(subRe, ' '); // remove subs so they don't leak into outer segments
|
|
91
|
+
for (const seg of stripped.split(/&&|\|\||;|\||\r?\n/)) {
|
|
92
|
+
if (seg.trim()) segments.push(seg);
|
|
93
|
+
}
|
|
94
|
+
return segments;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isSegmentExempt(seg) {
|
|
98
|
+
return SEGMENT_EXEMPT.some((re) => re.test(seg));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The pure decision. Returns { block: boolean, reason?: string }.
|
|
103
|
+
* Splits the command into segments and BLOCKS if ANY non-exempt segment is both
|
|
104
|
+
* destructive AND aimed at a memory path. Per-segment so an exempt verb in front
|
|
105
|
+
* (`echo …`, `git commit …`) cannot launder a chained real delete (B1/I1).
|
|
106
|
+
*/
|
|
107
|
+
export function decideGuard(cmd) {
|
|
108
|
+
if (typeof cmd !== 'string' || cmd.trim() === '') return { block: false };
|
|
109
|
+
const offending = splitSegments(cmd).some(
|
|
110
|
+
(seg) => !isSegmentExempt(seg) && isDestructive(seg) && touchesMemory(seg),
|
|
111
|
+
);
|
|
112
|
+
if (offending) {
|
|
113
|
+
return {
|
|
114
|
+
block: true,
|
|
115
|
+
reason:
|
|
116
|
+
'BLOCKED by the claude-memory-kit delete-guardrail: this command deletes a ' +
|
|
117
|
+
'memory path (context/ , the persona tier ~/.claude-memory-kit, or a memory ' +
|
|
118
|
+
'file). Memory is precious and a delete here is often non-recoverable. If you ' +
|
|
119
|
+
'REALLY mean to delete memory, do it by hand after a backup — or ask the user. ' +
|
|
120
|
+
'NEVER run a delete after a `cd` you have not verified with `pwd`.',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { block: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The shell-tool names across agents. Claude Code: `Bash` / `PowerShell`.
|
|
127
|
+
// Kiro / Amazon-Q: `execute_bash` (+ legacy alias `executeBash`); kiro-cli V3
|
|
128
|
+
// (2.9.0) RENAMED it to `execute_command` (D-198, observed live in the cut-gate).
|
|
129
|
+
// Payload `.tool_name` + `.tool_input.command` on STDIN — the SAME shape as
|
|
130
|
+
// Claude Code, so one bin guards every agent. D-192.
|
|
131
|
+
const SHELL_TOOLS = new Set([
|
|
132
|
+
'Bash',
|
|
133
|
+
'PowerShell',
|
|
134
|
+
'execute_bash',
|
|
135
|
+
'executeBash',
|
|
136
|
+
'execute_command', // kiro-cli V3 (2.9.0) rename — D-198
|
|
137
|
+
'shell',
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Evaluate a PreToolUse payload (already parsed) from EITHER Claude Code or Kiro
|
|
142
|
+
* — both deliver `{ tool_name, tool_input: { command } }` on stdin. Only shell
|
|
143
|
+
* tools are inspected; everything else is allowed. Returns { block, reason? }.
|
|
144
|
+
*/
|
|
145
|
+
export function evaluatePayload(payload) {
|
|
146
|
+
const tool = payload?.tool_name;
|
|
147
|
+
if (!SHELL_TOOLS.has(tool)) return { block: false };
|
|
148
|
+
const cmd = payload?.tool_input?.command;
|
|
149
|
+
if (typeof cmd !== 'string' || cmd === '') return { block: false };
|
|
150
|
+
return decideGuard(cmd);
|
|
151
|
+
}
|
|
@@ -253,7 +253,21 @@ export async function importAnthropicMemory({
|
|
|
253
253
|
})
|
|
254
254
|
.join('\n');
|
|
255
255
|
mkdirSync(join(projectRoot, 'context'), { recursive: true });
|
|
256
|
-
|
|
256
|
+
// Lint-clean append (MD022): guarantee exactly one blank line ABOVE the
|
|
257
|
+
// `## Imported …` heading when the target file already has content (the
|
|
258
|
+
// leading `\n` in sectionHeader assumes the file ends in `\n` — fragile if it
|
|
259
|
+
// doesn't). The blank below the heading is the `+ '\n'` after sectionHeader.
|
|
260
|
+
let prefix = '';
|
|
261
|
+
if (existsSync(targetPath)) {
|
|
262
|
+
const existing = readFileSync(targetPath, 'utf8');
|
|
263
|
+
if (existing.trim() !== '') {
|
|
264
|
+
prefix = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '' : '\n';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// sectionHeader already starts with `\n`, so a file ending in `\n` yields the
|
|
268
|
+
// needed blank line; prefix adds the missing one only when the file lacks a
|
|
269
|
+
// trailing newline.
|
|
270
|
+
appendFileSync(targetPath, prefix + sectionHeader + '\n' + bulletLines + '\n', 'utf8');
|
|
257
271
|
|
|
258
272
|
let accepted = 0;
|
|
259
273
|
for (const p of proposals) {
|
package/src/inject-context.mjs
CHANGED
|
@@ -31,7 +31,8 @@ import {
|
|
|
31
31
|
closeSync,
|
|
32
32
|
} from 'node:fs';
|
|
33
33
|
import { spawn } from 'node:child_process';
|
|
34
|
-
import { join } from 'node:path';
|
|
34
|
+
import { join, dirname } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
35
36
|
import { homedir } from 'node:os';
|
|
36
37
|
import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
|
|
37
38
|
import { nowIso } from './audit-log.mjs';
|
|
@@ -617,6 +618,32 @@ function enforceCap(orderedBlocks, capBytes, ts, reportCapBytes = capBytes) {
|
|
|
617
618
|
* Exposed so injectContext can override via dependency injection in tests
|
|
618
619
|
* (testSpawnLazy parameter) — production callers pass nothing.
|
|
619
620
|
*/
|
|
621
|
+
/**
|
|
622
|
+
* Resolve the path to `bin/cmk-compress-lazy.mjs` from THIS module's location
|
|
623
|
+
* (`src/` → `../bin/`), honoring the $CMK_COMPRESS_LAZY_PATH override. Returns
|
|
624
|
+
* null if it can't be found (→ the shell:true fallback).
|
|
625
|
+
*
|
|
626
|
+
* Why this exists (the cross-agent console-popup fix): the no-popup spawn (Task
|
|
627
|
+
* 81) only kicks in when `injectContext` receives a real `compressLazyPath` —
|
|
628
|
+
* otherwise `lazyCompressSpawnDescriptor` falls to the `shell:true` `.cmd` shim,
|
|
629
|
+
* which flashes a `node` console window on Windows. The Claude Code bin
|
|
630
|
+
* (`cmk-inject-context.mjs`) passed the path; the Kiro `cmk hook agentSpawn`
|
|
631
|
+
* path did NOT, so a real Kiro user got the popup (the cut-gate-kiro live find).
|
|
632
|
+
* Resolving it HERE (in injectContext's default) fixes EVERY caller — Claude
|
|
633
|
+
* bin, Kiro hook, any future agent — not just the ones that remember to pass it.
|
|
634
|
+
*/
|
|
635
|
+
export function resolveCompressLazyPath() {
|
|
636
|
+
const fromEnv = process.env.CMK_COMPRESS_LAZY_PATH;
|
|
637
|
+
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
|
638
|
+
try {
|
|
639
|
+
const here = dirname(fileURLToPath(import.meta.url)); // .../packages/cli/src
|
|
640
|
+
const candidate = join(here, '..', 'bin', 'cmk-compress-lazy.mjs');
|
|
641
|
+
return existsSync(candidate) ? candidate : null;
|
|
642
|
+
} catch {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
620
647
|
/**
|
|
621
648
|
* Pure spawn descriptor for the lazy-compress child (Task 81). Separated so the
|
|
622
649
|
* Door-3 contract (node-direct + windowsHide, no shell, when the path is known)
|
|
@@ -706,8 +733,12 @@ export function injectContext({
|
|
|
706
733
|
// Resolved path to cmk-compress-lazy.mjs (passed by the bin wrapper, which
|
|
707
734
|
// knows the install layout). Lets spawnLazyCompress run `node <path>`
|
|
708
735
|
// directly instead of the shell:true `.cmd` shim — the Windows
|
|
709
|
-
// console-popup fix (Task 81). Absent →
|
|
710
|
-
|
|
736
|
+
// console-popup fix (Task 81). Absent → self-resolve from this module's
|
|
737
|
+
// location (resolveCompressLazyPath), so EVERY caller gets the no-popup
|
|
738
|
+
// node-direct spawn — not just the Claude bin that passed it explicitly. A
|
|
739
|
+
// caller may still override (e.g. tests). Only a genuinely-unfindable bin
|
|
740
|
+
// (corrupt install) falls to the shell:true descriptor.
|
|
741
|
+
compressLazyPath = resolveCompressLazyPath(),
|
|
711
742
|
} = {}) {
|
|
712
743
|
const ts = now ?? nowIso();
|
|
713
744
|
const cap = typeof capBytes === 'number' ? capBytes : DEFAULT_CAP_BYTES;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// install-agent.mjs — wire a per-agent profile's legs into a project (Task 50.E/50.F).
|
|
2
|
+
//
|
|
3
|
+
// Given a profile (DATA from agent-profiles.mjs) + a project root, this lands the
|
|
4
|
+
// profile's three legs in its DECLARED paths, reusing the kit's shared primitives:
|
|
5
|
+
// - MCP registration → mutateAgentConfig (touch-only-our-keys, refuse-on-parse-error)
|
|
6
|
+
// - hook entry → mutateAgentConfig (the agent's hook-config file)
|
|
7
|
+
// - instruction file → a managed marker block (byte-preserving install/uninstall)
|
|
8
|
+
//
|
|
9
|
+
// This is the per-agent path. install.mjs keeps its existing Claude-Code wiring
|
|
10
|
+
// for the default `--ide claude-code` route (regression-proof, D-180 / 50.E); this
|
|
11
|
+
// module handles the OTHER agents (Kiro first). The kit's core — store, compression,
|
|
12
|
+
// search, CLI, MCP server — is identical across agents; only these legs differ.
|
|
13
|
+
//
|
|
14
|
+
// Public surface:
|
|
15
|
+
// installAgent({ projectRoot, profile }) → { action, agent, changed, legs, errors? }
|
|
16
|
+
// uninstallAgent({ projectRoot, profile }) → { action, agent, changed }
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { mutateAgentConfig, atomicWrite } from './mutate-agent-config.mjs';
|
|
21
|
+
import {
|
|
22
|
+
removeJsonKey,
|
|
23
|
+
pruneEmptyParent,
|
|
24
|
+
escapeRe,
|
|
25
|
+
trimLeadingNewlines,
|
|
26
|
+
trimTrailingNewlines,
|
|
27
|
+
} from './managed-block.mjs';
|
|
28
|
+
|
|
29
|
+
// The kit's MCP server entry — same shape settings-hooks.mjs writes for Claude
|
|
30
|
+
// Code (`cmk mcp serve` over stdio). Agent-neutral: every agent registers the
|
|
31
|
+
// same server; only the FILE it goes in differs (profile.mcp.path).
|
|
32
|
+
const MCP_ENTRY = Object.freeze({ type: 'stdio', command: 'cmk', args: ['mcp', 'serve'] });
|
|
33
|
+
const MCP_SERVER_NAME = 'claude-memory-kit';
|
|
34
|
+
|
|
35
|
+
// The kit's lifecycle-hook commands, keyed by the ABSTRACT event the profile's
|
|
36
|
+
// eventMap translates to the agent's concrete event name. Only the events an
|
|
37
|
+
// agent supports (present in its eventMap) get wired.
|
|
38
|
+
const HOOK_COMMANDS = Object.freeze({
|
|
39
|
+
sessionStart: 'cmk-inject-context',
|
|
40
|
+
promptSubmit: 'cmk-capture-prompt',
|
|
41
|
+
postEdit: 'cmk-observe-edit',
|
|
42
|
+
turnEnd: 'cmk-capture-turn',
|
|
43
|
+
sessionEnd: 'cmk-compress-session',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Steering / instruction-file body. Kiro reads `inclusion: always` frontmatter
|
|
47
|
+
// to keep this in context every session (kiro.dev primary-verified). The body is
|
|
48
|
+
// intentionally short — it points the agent at the kit's recall surface; the rich
|
|
49
|
+
// memory lives in context/.
|
|
50
|
+
const INSTRUCTION_BODY = [
|
|
51
|
+
'# claude-memory-kit',
|
|
52
|
+
'',
|
|
53
|
+
'This project uses claude-memory-kit for durable, in-repo memory across sessions.',
|
|
54
|
+
'Recall before re-deriving: run `cmk search "<topic>"` for prior decisions, preferences,',
|
|
55
|
+
'and project facts; the curated tiers live under `context/`. Capture durable facts with',
|
|
56
|
+
'`cmk remember` — never hand-edit the memory files.',
|
|
57
|
+
].join('\n');
|
|
58
|
+
|
|
59
|
+
const MARK_START = '<!-- claude-memory-kit:start -->';
|
|
60
|
+
const MARK_END = '<!-- claude-memory-kit:end -->';
|
|
61
|
+
|
|
62
|
+
export function installAgent({ projectRoot, profile }) {
|
|
63
|
+
if (!projectRoot) throw new Error('installAgent: projectRoot is required');
|
|
64
|
+
if (!profile || !profile.name) throw new Error('installAgent: a profile is required');
|
|
65
|
+
|
|
66
|
+
const legs = {};
|
|
67
|
+
const errors = [];
|
|
68
|
+
let changed = false;
|
|
69
|
+
|
|
70
|
+
// ── MCP leg ───────────────────────────────────────────────────────────────
|
|
71
|
+
if (profile.mcp) {
|
|
72
|
+
const r = mutateAgentConfig({
|
|
73
|
+
path: join(projectRoot, profile.mcp.path),
|
|
74
|
+
format: 'json',
|
|
75
|
+
keyPath: [profile.mcp.serversKey, MCP_SERVER_NAME],
|
|
76
|
+
entry: MCP_ENTRY,
|
|
77
|
+
});
|
|
78
|
+
legs.mcp = r.action;
|
|
79
|
+
if (r.action === 'error') errors.push({ leg: 'mcp', ...r });
|
|
80
|
+
else if (r.changed) changed = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── hooks leg ───────────────────────────────────────────────────────────────
|
|
84
|
+
// Only if MCP didn't already error (refuse-to-clobber means a corrupt config
|
|
85
|
+
// should halt this agent's install — report, don't push past it).
|
|
86
|
+
if (profile.hooks && errors.length === 0) {
|
|
87
|
+
const hookEntry = buildHookEntry(profile);
|
|
88
|
+
const r = mutateAgentConfig({
|
|
89
|
+
path: join(projectRoot, profile.hooks.path),
|
|
90
|
+
format: 'json',
|
|
91
|
+
keyPath: ['hooks'],
|
|
92
|
+
entry: hookEntry,
|
|
93
|
+
});
|
|
94
|
+
legs.hooks = r.action;
|
|
95
|
+
if (r.action === 'error') errors.push({ leg: 'hooks', ...r });
|
|
96
|
+
else if (r.changed) changed = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── instruction leg (managed marker block) ──────────────────────────────────
|
|
100
|
+
if (errors.length === 0) {
|
|
101
|
+
const instrPath = join(projectRoot, profile.instructionFile);
|
|
102
|
+
const r = writeInstructionFile(instrPath, profile);
|
|
103
|
+
legs.instruction = r.action;
|
|
104
|
+
if (r.changed) changed = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (errors.length > 0) {
|
|
108
|
+
return { action: 'error', agent: profile.name, changed, legs, errors };
|
|
109
|
+
}
|
|
110
|
+
return { action: 'installed', agent: profile.name, changed, legs };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function uninstallAgent({ projectRoot, profile }) {
|
|
114
|
+
if (!projectRoot) throw new Error('uninstallAgent: projectRoot is required');
|
|
115
|
+
if (!profile || !profile.name) throw new Error('uninstallAgent: a profile is required');
|
|
116
|
+
|
|
117
|
+
let changed = false;
|
|
118
|
+
|
|
119
|
+
// MCP: remove only our server key, preserve siblings.
|
|
120
|
+
if (profile.mcp) {
|
|
121
|
+
const p = join(projectRoot, profile.mcp.path);
|
|
122
|
+
if (removeJsonKey(p, [profile.mcp.serversKey, MCP_SERVER_NAME])) changed = true;
|
|
123
|
+
// prune an emptied servers object we leave behind (no kit-shaped residue).
|
|
124
|
+
pruneEmptyParent(p, [profile.mcp.serversKey]);
|
|
125
|
+
}
|
|
126
|
+
// hooks: remove ONLY the event keys WE wrote (symmetry with the MCP leg —
|
|
127
|
+
// remove our keys, preserve any the user added to the same `hooks` object).
|
|
128
|
+
// This is safe even if a future profile points hooks.path at a shared file.
|
|
129
|
+
if (profile.hooks) {
|
|
130
|
+
const p = join(projectRoot, profile.hooks.path);
|
|
131
|
+
const ourEvents = Object.keys(buildHookEntry(profile));
|
|
132
|
+
for (const ev of ourEvents) {
|
|
133
|
+
if (removeJsonKey(p, ['hooks', ev])) changed = true;
|
|
134
|
+
}
|
|
135
|
+
// prune an emptied `hooks` object we leave behind (no residue).
|
|
136
|
+
pruneEmptyParent(p, ['hooks']);
|
|
137
|
+
}
|
|
138
|
+
// instruction: strip our marker block, byte-preserve the rest.
|
|
139
|
+
const instrPath = join(projectRoot, profile.instructionFile);
|
|
140
|
+
if (removeInstructionBlock(instrPath)) changed = true;
|
|
141
|
+
|
|
142
|
+
return { action: 'uninstalled', agent: profile.name, changed };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── internal helpers ───────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
// Build the agent's hook object: { <concreteEvent>: [ {command} ] } for each
|
|
148
|
+
// abstract event the profile maps + the kit has a command for.
|
|
149
|
+
function buildHookEntry(profile) {
|
|
150
|
+
const hooks = {};
|
|
151
|
+
for (const [abstractEvent, concreteEvent] of Object.entries(profile.hooks.eventMap)) {
|
|
152
|
+
const command = HOOK_COMMANDS[abstractEvent];
|
|
153
|
+
if (command) hooks[concreteEvent] = [{ command }];
|
|
154
|
+
}
|
|
155
|
+
return hooks;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Write the instruction file with a managed marker block + agent-specific
|
|
159
|
+
// frontmatter (Kiro wants `inclusion: always`). Idempotent: re-writing identical
|
|
160
|
+
// content reports changed:false.
|
|
161
|
+
function writeInstructionFile(path, profile) {
|
|
162
|
+
const frontmatter = needsInclusionFrontmatter(profile) ? '---\ninclusion: always\n---\n\n' : '';
|
|
163
|
+
const block = `${MARK_START}\n${INSTRUCTION_BODY}\n${MARK_END}`;
|
|
164
|
+
const desired = `${frontmatter}${block}\n`;
|
|
165
|
+
|
|
166
|
+
let existing = '';
|
|
167
|
+
if (existsSync(path)) existing = readFileSync(path, 'utf8');
|
|
168
|
+
|
|
169
|
+
let next;
|
|
170
|
+
if (existing === '') {
|
|
171
|
+
next = desired;
|
|
172
|
+
} else if (existing.includes(MARK_START) && existing.includes(MARK_END)) {
|
|
173
|
+
// refresh in place — replace only the managed block, byte-preserve the rest.
|
|
174
|
+
next = existing.replace(
|
|
175
|
+
new RegExp(`${escapeRe(MARK_START)}[\\s\\S]*?${escapeRe(MARK_END)}`),
|
|
176
|
+
block,
|
|
177
|
+
);
|
|
178
|
+
} else {
|
|
179
|
+
// append our block to the user's existing file.
|
|
180
|
+
next = `${trimTrailingNewlines(existing)}\n\n${block}\n`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (next === existing) return { action: 'unchanged', changed: false };
|
|
184
|
+
atomicWrite(path, next);
|
|
185
|
+
return { action: existing === '' ? 'created' : 'updated', changed: true };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function removeInstructionBlock(path) {
|
|
189
|
+
if (!existsSync(path)) return false;
|
|
190
|
+
const existing = readFileSync(path, 'utf8');
|
|
191
|
+
if (!existing.includes(MARK_START)) return false;
|
|
192
|
+
// Strip our block (+ surrounding blank lines). The inner [\s\S]*? is lazy +
|
|
193
|
+
// bounded by two fixed literal delimiters (no nested quantifier) → linear, not
|
|
194
|
+
// ReDoS-prone. The newline trims are done WITHOUT regex (no `\n*$`/`^\n+`
|
|
195
|
+
// super-linear shapes) — see trimLeadingNewlines/trimTrailingNewlines.
|
|
196
|
+
const blockRe = new RegExp(`${escapeRe(MARK_START)}[\\s\\S]*?${escapeRe(MARK_END)}`);
|
|
197
|
+
const withoutBlock = existing.replace(blockRe, '');
|
|
198
|
+
const stripped = trimLeadingNewlines(collapseBlankRun(withoutBlock));
|
|
199
|
+
atomicWrite(path, stripped);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// removeJsonKey / pruneEmptyParent / escapeRe / trim{Leading,Trailing}Newlines
|
|
204
|
+
// are now shared from managed-block.mjs (deduped — the kit's shared-module
|
|
205
|
+
// discipline; install-kiro.mjs uses the same source).
|
|
206
|
+
|
|
207
|
+
function needsInclusionFrontmatter(profile) {
|
|
208
|
+
// Kiro steering files use `inclusion: always`. Driven by the profile's
|
|
209
|
+
// instruction path living under a steering dir — kept simple for v0.4.0
|
|
210
|
+
// (only Kiro needs it); generalize when a second steering-style agent lands.
|
|
211
|
+
return profile.instructionFile.includes('/steering/') || profile.name === 'kiro';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Collapse a run of 2+ blank lines left where our block was removed into a
|
|
215
|
+
// single newline, so an uninstall doesn't leave a widening gap.
|
|
216
|
+
function collapseBlankRun(s) {
|
|
217
|
+
// split on newlines, drop empty segments created by the removed block's
|
|
218
|
+
// surrounding blanks, rejoin — no regex, no backtracking.
|
|
219
|
+
return s.replace(/\n{3,}/g, '\n\n');
|
|
220
|
+
}
|