@lh8ppl/claude-memory-kit 0.3.5 → 0.4.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.
Files changed (53) hide show
  1. package/README.md +137 -50
  2. package/bin/cmk-approve-permission.mjs +62 -0
  3. package/bin/cmk-daily-distill.mjs +14 -0
  4. package/bin/cmk-guard-memory.mjs +57 -0
  5. package/bin/cmk-inject-context.mjs +12 -0
  6. package/bin/cmk-weekly-curate.mjs +12 -0
  7. package/package.json +4 -2
  8. package/src/agent-profile.mjs +115 -0
  9. package/src/agent-profiles.mjs +118 -0
  10. package/src/approve-permission.mjs +92 -0
  11. package/src/auto-extract.mjs +17 -10
  12. package/src/auto-persona.mjs +11 -4
  13. package/src/compaction-state.mjs +204 -0
  14. package/src/compress-session.mjs +13 -1
  15. package/src/config-core.mjs +7 -9
  16. package/src/decisions-journal.mjs +71 -3
  17. package/src/doctor.mjs +128 -5
  18. package/src/guard-memory.mjs +151 -0
  19. package/src/import-anthropic-memory.mjs +15 -1
  20. package/src/inject-context.mjs +42 -18
  21. package/src/install-agent.mjs +220 -0
  22. package/src/install-kiro.mjs +287 -0
  23. package/src/install.mjs +53 -7
  24. package/src/kiro-cli-agent.mjs +270 -0
  25. package/src/kiro-constants.mjs +19 -0
  26. package/src/kiro-hook-bin.mjs +105 -0
  27. package/src/kiro-hook-command.mjs +67 -0
  28. package/src/kiro-hook-dispatch.mjs +115 -0
  29. package/src/kiro-ide-hooks.mjs +219 -0
  30. package/src/kiro-permissions.mjs +175 -0
  31. package/src/kiro-skills.mjs +96 -0
  32. package/src/kiro-transcript.mjs +366 -0
  33. package/src/kiro-trusted-commands.mjs +130 -0
  34. package/src/lazy-compress.mjs +43 -110
  35. package/src/managed-block.mjs +138 -0
  36. package/src/memory-write.mjs +23 -8
  37. package/src/mutate-agent-config.mjs +243 -0
  38. package/src/read-json.mjs +43 -0
  39. package/src/register-crons.mjs +31 -0
  40. package/src/reindex.mjs +15 -2
  41. package/src/repair.mjs +39 -3
  42. package/src/result-shapes.mjs +8 -0
  43. package/src/review-queue.mjs +3 -0
  44. package/src/scratchpad.mjs +12 -2
  45. package/src/search.mjs +12 -5
  46. package/src/semantic-backend.mjs +7 -9
  47. package/src/settings-hooks.mjs +70 -3
  48. package/src/subcommands.mjs +360 -27
  49. package/src/tier-paths.mjs +82 -1
  50. package/src/weekly-curate.mjs +6 -2
  51. package/template/.claude/skills/memory-search/SKILL.md +14 -1
  52. package/template/.claude/skills/memory-write/SKILL.md +37 -1
  53. package/template/project/memory/INDEX.md.template +1 -1
@@ -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
+ }
@@ -0,0 +1,287 @@
1
+ // install-kiro.mjs — the Kiro orchestrator, all surfaces (Task 50, D-182).
2
+ //
3
+ // Kiro needs its OWN installer branch (D-182): the generic installAgent assumed
4
+ // a Claude-Code-shaped model (one settings file + one instruction file) that
5
+ // doesn't fit Kiro's distinct surfaces. This composes the verified surface
6
+ // modules:
7
+ // MCP → .kiro/settings/mcp.json (mcpServers.cmk) via mutateAgentConfig
8
+ // steering → .kiro/steering/cmk.md (inclusion: always), managed marker block
9
+ // skills → .kiro/skills/{memory-search,memory-write}/ via installKiroSkills
10
+ // IDE hooks → .kiro/hooks/cmk-{capture,inject}.kiro.hook via installKiroIdeHooks
11
+ // CLI agent → ~/.kiro/agents/cmk.json + ~/.kiro/settings/cli.json (agentSpawn/stop hooks + default-agent
12
+ // default-agent) via installKiroCliAgent — for kiro-cli users
13
+ //
14
+ // IDE hooks auto-fire with no agent selection; the CLI agent needs cmk to be the
15
+ // default agent (guarded — never clobbers a user's existing default). Both hook
16
+ // surfaces reuse the SAME `cmk hook <event>` dispatcher (the shared core).
17
+ //
18
+ // Each leg reuses the kit's safe primitives: mutateAgentConfig (touch-only-our-
19
+ // keys, refuse-to-clobber-on-parse-error), managed marker blocks (byte-preserve),
20
+ // and the skills/hooks copiers (idempotent, remove-only-ours on uninstall).
21
+ //
22
+ // Public surface:
23
+ // installKiro({ projectRoot, awsDir? }) → { action, changed, surfaces, cliDefaultAgent, errors? }
24
+ // uninstallKiro({ projectRoot, awsDir? }) → { action, changed }
25
+ // (awsDir is the kept-for-back-compat alias for the CLI-agent sandbox base —
26
+ // it sandboxes the CLI-agent leg in tests; production writes the real ~/.kiro
27
+ // [agents/cmk.json + settings/cli.json], NOT ~/.aws. The name predates the
28
+ // D-198 ~/.aws→~/.kiro relocation; MEMORY_KIT_KIRO_DIR is the current env var.)
29
+
30
+ import { join } from 'node:path';
31
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
32
+ import { mutateAgentConfig } from './mutate-agent-config.mjs';
33
+ import { installKiroSkills, uninstallKiroSkills } from './kiro-skills.mjs';
34
+ import { installKiroIdeHooks, uninstallKiroIdeHooks } from './kiro-ide-hooks.mjs';
35
+ import { installKiroCliAgent, uninstallKiroCliAgent } from './kiro-cli-agent.mjs';
36
+ import { installKiroTrustedCommands, uninstallKiroTrustedCommands } from './kiro-trusted-commands.mjs';
37
+ import { installKiroPermissions, uninstallKiroPermissions } from './kiro-permissions.mjs';
38
+ import {
39
+ writeManagedBlock,
40
+ removeManagedBlock,
41
+ removeJsonKey,
42
+ pruneEmptyParent,
43
+ } from './managed-block.mjs';
44
+
45
+ const MCP_PATH = ['settings', 'mcp.json'];
46
+ const MCP_SERVERS_KEY = 'mcpServers';
47
+ const MCP_SERVER_NAME = 'claude-memory-kit';
48
+ // autoApprove pre-approves the kit's MCP tools so Kiro runs them WITHOUT a
49
+ // per-call "Reject / Trust / Run" prompt (found live in cut-gate-kiro Session 1:
50
+ // Kiro gates MCP TOOL calls separately from the shell-command hooks D-194 wired,
51
+ // so mk_remember etc. prompted every time). Verified shape from kiro.dev/docs/mcp:
52
+ // an `autoApprove` array of bare tool names INSIDE the server entry. Explicit
53
+ // list of the 11 kit tools — scoped to OUR tools, never a `"*"` wildcard (which
54
+ // would auto-approve any tool the server ever adds). mk_forget is safe to
55
+ // auto-approve the CALL: it has its own two-step confirm-token before deleting.
56
+ // MCP_AUTO_APPROVE now lives in the leaf kiro-constants.mjs (shared with
57
+ // kiro-permissions, no import cycle); imported for local use + re-exported for
58
+ // back-compat (existing importers of install-kiro.MCP_AUTO_APPROVE keep working).
59
+ import { MCP_AUTO_APPROVE } from './kiro-constants.mjs';
60
+ export { MCP_AUTO_APPROVE };
61
+ // The MCP entry for `.kiro/settings/mcp.json` — the KIRO IDE's MCP surface (the
62
+ // IDE wires MCP tools to the chat; the kiro-cli agent sets includeMcpJson:false
63
+ // and does NOT use MCP — it uses the `cmk remember`/`cmk search` shell commands).
64
+ // `cmk mcp serve` resolves its project from CLAUDE_PROJECT_DIR (Claude Code) or
65
+ // the launch cwd; the IDE launches from the workspace, so no per-project arg is
66
+ // needed here (the IDE worked without one).
67
+ const MCP_ENTRY = Object.freeze({
68
+ type: 'stdio',
69
+ command: 'cmk',
70
+ args: ['mcp', 'serve'],
71
+ autoApprove: MCP_AUTO_APPROVE,
72
+ });
73
+
74
+ const STEERING_PATH = ['steering', 'cmk.md'];
75
+ const STEERING_FRONTMATTER = '---\ninclusion: always\n---\n\n';
76
+ const MEMORY_BODY = [
77
+ '# claude-memory-kit',
78
+ '',
79
+ 'This project uses claude-memory-kit for durable, in-repo memory across sessions.',
80
+ 'Recall before re-deriving: run `cmk search "<topic>"` for prior decisions,',
81
+ 'preferences, and project facts; the curated tiers live under `context/`.',
82
+ 'Capture durable facts with `cmk remember` — never hand-edit the memory files.',
83
+ ].join('\n');
84
+ const STEERING_BODY = MEMORY_BODY;
85
+
86
+ // AGENTS.md — Kiro's real, always-loaded instruction file (kiro.dev/docs/steering:
87
+ // auto-included from the project root, no inclusion modes). The CLI agent-config's
88
+ // `prompt: file://AGENTS.md` resolves to THIS (D-188). A managed block so it
89
+ // coexists with any user AGENTS.md content; no frontmatter (AGENTS.md is the
90
+ // cross-tool standard — inclusion modes are a Kiro-steering-only feature).
91
+ const AGENTS_MD_PATH = 'AGENTS.md';
92
+ const AGENTS_MD_BODY = MEMORY_BODY;
93
+
94
+ export function installKiro({ projectRoot, awsDir } = {}) {
95
+ if (!projectRoot) throw new Error('installKiro: projectRoot is required');
96
+ const kiro = (parts) => join(projectRoot, '.kiro', ...parts);
97
+
98
+ const surfaces = [];
99
+ const errors = [];
100
+ const warnings = []; // non-fatal surface failures (e.g. a corrupt user .vscode/settings.json)
101
+ let changed = false;
102
+ let cliDefaultAgent = null;
103
+
104
+ // ── MCP ──────────────────────────────────────────────────────────────────
105
+ const mcp = mutateAgentConfig({
106
+ path: kiro(MCP_PATH),
107
+ format: 'json',
108
+ keyPath: [MCP_SERVERS_KEY, MCP_SERVER_NAME],
109
+ entry: MCP_ENTRY,
110
+ });
111
+ if (mcp.action === 'error') {
112
+ errors.push({ surface: 'mcp', ...mcp });
113
+ } else {
114
+ surfaces.push('mcp');
115
+ if (mcp.changed) changed = true;
116
+ }
117
+
118
+ // If MCP refused (corrupt config), halt — report, don't push past it.
119
+ if (errors.length > 0) {
120
+ return { action: 'error', changed, surfaces, errors };
121
+ }
122
+
123
+ // ── steering ───────────────────────────────────────────────────────────────
124
+ if (writeManagedBlock(kiro(STEERING_PATH), { body: STEERING_BODY, frontmatter: STEERING_FRONTMATTER })) changed = true;
125
+ surfaces.push('steering');
126
+
127
+ // ── AGENTS.md (project root) — Kiro's always-loaded instruction file; the CLI
128
+ // agent-config's prompt:file://AGENTS.md points here (D-188). Managed block,
129
+ // no frontmatter. Coexists with a Claude-Code CLAUDE.md (each agent reads
130
+ // its own) and with any user-authored AGENTS.md content.
131
+ if (writeManagedBlock(join(projectRoot, AGENTS_MD_PATH), { body: AGENTS_MD_BODY })) changed = true;
132
+ surfaces.push('agents-md');
133
+
134
+ // ── skills ───────────────────────────────────────────────────────────────
135
+ const skills = installKiroSkills({ projectRoot });
136
+ if (skills.changed) changed = true;
137
+ surfaces.push('skills');
138
+
139
+ // ── IDE hooks ──────────────────────────────────────────────────────────────
140
+ const hooks = installKiroIdeHooks({ projectRoot });
141
+ if (hooks.changed) changed = true;
142
+ surfaces.push('ide-hooks');
143
+
144
+ // ── trusted commands (D-194) — pre-trust the kit's hook commands in the
145
+ // workspace .vscode/settings.json so Kiro auto-runs them instead of
146
+ // prompting "Run / Reject" on every hook fire. Without this the IDE hooks
147
+ // above are wired but not silent. Array-union (preserves a user's existing
148
+ // trustedCommands); refuse-to-clobber a corrupt settings.json.
149
+ //
150
+ // NON-FATAL on error, UNLIKE the MCP leg above (skill-review I1): MCP is the
151
+ // FIRST surface (a corrupt MCP config → clean abort, nothing written yet).
152
+ // Trust runs LAST-but-one, after MCP/steering/AGENTS.md/skills/ide-hooks all
153
+ // succeeded — and it's the LEAST critical surface (if it can't write, the
154
+ // hooks still fire, they just prompt). So a user's pre-existing malformed
155
+ // .vscode/settings.json must NOT abort an otherwise-good install: record the
156
+ // warning, skip the surface, and continue to the CLI agent.
157
+ const trust = installKiroTrustedCommands({ projectRoot });
158
+ if (trust.action === 'error') {
159
+ warnings.push({ surface: 'trusted-commands', ...trust });
160
+ } else {
161
+ if (trust.changed) changed = true;
162
+ surfaces.push('trusted-commands');
163
+ }
164
+
165
+ // ── IDE-1.0 permissions (50.N.5 / D-203h/i) — Kiro IDE 1.0's AUTHORITATIVE trust
166
+ // is ~/.kiro/workspace-roots/<hash>/permissions.yaml (NOT .vscode; Kiro
167
+ // migrates .vscode into it). The `.vscode` trustedCommands above keep 0.x +
168
+ // pre-migration working; this pre-trusts the kit's shell + mcp + SKILL on 1.0
169
+ // so even the first memory-write skill-load runs prompt-free (the one thing
170
+ // `.vscode` has NO setting for). Best-effort like trusted-commands — a write
171
+ // failure never aborts the install.
172
+ let permissions;
173
+ try {
174
+ permissions = installKiroPermissions({ projectRoot });
175
+ if (permissions.changed) changed = true;
176
+ surfaces.push('permissions');
177
+ } catch (err) {
178
+ warnings.push({ surface: 'permissions', action: 'error', error: err?.message ?? String(err) });
179
+ }
180
+
181
+ // ── CLI agent-config (kiro-cli, user-tier) — agentSpawn/stop hooks + the
182
+ // guarded default-agent. Covers terminal `kiro-cli` users; the IDE hooks
183
+ // above cover the GUI. Both reuse the same `cmk hook` dispatcher.
184
+ const cli = installKiroCliAgent({ awsDir });
185
+ cliDefaultAgent = cli.defaultAgent; // 'set' | 'skipped-existing'
186
+ if (cli.changed) changed = true;
187
+ surfaces.push('cli-agent');
188
+
189
+ const result = { action: 'installed', changed, surfaces, cliDefaultAgent };
190
+ if (warnings.length > 0) result.warnings = warnings;
191
+ return result;
192
+ }
193
+
194
+ export function uninstallKiro({ projectRoot, awsDir } = {}) {
195
+ if (!projectRoot) throw new Error('uninstallKiro: projectRoot is required');
196
+ const kiro = (parts) => join(projectRoot, '.kiro', ...parts);
197
+ let changed = false;
198
+
199
+ // MCP: remove only our server key, prune an emptied servers object.
200
+ const mcpPath = kiro(MCP_PATH);
201
+ const mcpTouched = removeJsonKey(mcpPath, [MCP_SERVERS_KEY, MCP_SERVER_NAME]);
202
+ if (mcpTouched) changed = true;
203
+ pruneEmptyParent(mcpPath, [MCP_SERVERS_KEY]);
204
+
205
+ // steering: strip our marker block (byte-preserve the rest).
206
+ const steeringPath = kiro(STEERING_PATH);
207
+ const steeringTouched = removeManagedBlock(steeringPath);
208
+ if (steeringTouched) changed = true;
209
+
210
+ // AGENTS.md: strip our marker block only (a user's own AGENTS.md content,
211
+ // outside our markers, is byte-preserved).
212
+ const agentsMdPath = join(projectRoot, AGENTS_MD_PATH);
213
+ const agentsMdTouched = removeManagedBlock(agentsMdPath);
214
+ if (agentsMdTouched) changed = true;
215
+
216
+ // No husks (D-191): a file the kit created that uninstall just emptied —
217
+ // an empty AGENTS.md, a `{}` mcp.json, a frontmatter-only steering file —
218
+ // should be removed, not left as a confusing shell. removeIfHusk deletes
219
+ // ONLY when no user content remains. GATED on "the kit's removal step actually
220
+ // changed THIS file" (B2 defense-in-depth) — so a pristine, never-kit-managed
221
+ // file is never even a deletion candidate, narrowing the blast radius.
222
+ if (agentsMdTouched && removeIfHusk(agentsMdPath)) changed = true;
223
+ if (steeringTouched && removeIfHusk(steeringPath)) changed = true;
224
+ if (mcpTouched && removeIfHusk(mcpPath)) changed = true;
225
+
226
+ // skills + IDE hooks + CLI agent-config: remove our files only.
227
+ if (uninstallKiroSkills({ projectRoot }).changed) changed = true;
228
+ if (uninstallKiroIdeHooks({ projectRoot }).changed) changed = true;
229
+ if (uninstallKiroCliAgent({ awsDir }).changed) changed = true;
230
+
231
+ // trusted commands (D-194): remove ONLY our patterns from
232
+ // .vscode/settings.json, preserving the user's; prune an emptied key.
233
+ if (uninstallKiroTrustedCommands({ projectRoot }).changed) changed = true;
234
+
235
+ // IDE-1.0 permissions (50.N.5): remove ONLY our rules from
236
+ // ~/.kiro/workspace-roots/<hash>/permissions.yaml, preserving the user's.
237
+ // Best-effort (the file is per-user, may be absent); never abort uninstall.
238
+ try {
239
+ if (uninstallKiroPermissions({ projectRoot }).changed) changed = true;
240
+ } catch {
241
+ /* best-effort — a missing/locked permissions.yaml never blocks uninstall */
242
+ }
243
+
244
+ return { action: 'uninstalled', changed };
245
+ }
246
+
247
+ // Is `trimmed` ONLY a YAML frontmatter block (opener `---`, a closing `---`,
248
+ // and nothing but blank lines after the FIRST closing fence)? Line-scan, not a
249
+ // backtracking regex (the managed-block.mjs ReDoS-safe-string-utils discipline)
250
+ // — AND, critically, the closing fence must be the FIRST `---` after the opener
251
+ // with nothing meaningful after it. A naive `---[\s\S]*?---$` regex backtracks
252
+ // to a LATER `---` (a user's horizontal rule) and would match — then DELETE — a
253
+ // file full of user prose bordered by `---` (the skill-review B1 data-loss bug).
254
+ function isOnlyFrontmatter(trimmed) {
255
+ const lines = trimmed.split(/\r?\n/);
256
+ if (lines[0] !== '---') return false;
257
+ const close = lines.indexOf('---', 1);
258
+ if (close === -1) return false;
259
+ // nothing but blank lines may follow the first closing fence
260
+ return lines.slice(close + 1).every((l) => l.trim() === '');
261
+ }
262
+
263
+ // Delete a file the kit created IFF uninstall left it with no real content —
264
+ // i.e. it's now empty, an empty JSON object (`{}`), or only YAML frontmatter.
265
+ // Anything else (user prose, a sibling MCP server, user frontmatter+body) means
266
+ // real content remains → keep the file. Conservative by construction: a
267
+ // non-husk is never touched. Returns true if a file was removed.
268
+ // (D-191 — no empty husks after a Kiro uninstall; B1-fixed predicate.)
269
+ function removeIfHusk(path) {
270
+ if (!existsSync(path)) return false;
271
+ let raw;
272
+ try {
273
+ raw = readFileSync(path, 'utf8');
274
+ } catch {
275
+ return false;
276
+ }
277
+ const trimmed = raw.trim();
278
+ if (trimmed === '' || trimmed === '{}' || isOnlyFrontmatter(trimmed)) {
279
+ try {
280
+ rmSync(path, { force: true });
281
+ return true;
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+ return false;
287
+ }
package/src/install.mjs CHANGED
@@ -371,8 +371,13 @@ export async function install(options = {}) {
371
371
  // design §1.3. Same boundary as the tiers: idempotent skip-existing +
372
372
  // over-mutation-safe (a hand-edited skill survives a re-install). The skill
373
373
  // files carry no {{placeholders}}, so renderTemplate is a byte-passthrough.
374
+ //
375
+ // `.claude/skills/` is CLAUDE-CODE-SPECIFIC. A `--ide kiro` install gets its
376
+ // skills at `.kiro/skills/` (written by the Kiro orchestrator), so it passes
377
+ // skipClaudeFiles to avoid leaving a dead Claude skills dir on a Kiro project
378
+ // (the cut-gate find: a Kiro user shouldn't carry Claude Code's skill files).
374
379
  const skillsSrc = join(templateDir, '.claude', 'skills');
375
- if (existsSync(skillsSrc)) {
380
+ if (!options.skipClaudeFiles && existsSync(skillsSrc)) {
376
381
  installTier(skillsSrc, join(projectRoot, '.claude', 'skills'), {
377
382
  created,
378
383
  skipped,
@@ -389,10 +394,18 @@ export async function install(options = {}) {
389
394
 
390
395
  // CLAUDE.md loader block — Task 4. Read the block content from the kit's
391
396
  // template/ and inject (or refresh) it inside marker delimiters. Never
392
- // touches content outside the markers.
397
+ // touches content outside the markers. CLAUDE.md is CLAUDE-CODE-SPECIFIC
398
+ // (Kiro reads AGENTS.md + steering, not CLAUDE.md) — a --ide kiro install
399
+ // passes skipClaudeFiles so it doesn't drop a CLAUDE.md the Kiro user can't
400
+ // use (D-188). An EXISTING CLAUDE.md from a prior Claude-Code install is left
401
+ // untouched regardless (we simply don't write a fresh one).
393
402
  const claudeMdTemplatePath = join(templateDir, 'CLAUDE.md.template');
394
403
  let claudeMd = { action: 'skipped', path: join(projectRoot, 'CLAUDE.md') };
395
- if (existsSync(claudeMdTemplatePath)) {
404
+ if (options.skipClaudeFiles) {
405
+ // a non-Claude-Code (--ide kiro) install: CLAUDE.md is intentionally not
406
+ // written; an existing one is left untouched. NOT an error.
407
+ claudeMd = { action: 'skipped', reason: 'non-claude-code-agent', path: join(projectRoot, 'CLAUDE.md') };
408
+ } else if (existsSync(claudeMdTemplatePath)) {
396
409
  const content = readFileSync(claudeMdTemplatePath, 'utf8');
397
410
  try {
398
411
  claudeMd = injectClaudeMdBlock({ projectRoot, content, version, force });
@@ -481,7 +494,12 @@ export async function install(options = {}) {
481
494
  // the pin-off; checked first below).
482
495
  let semantic = { action: 'skipped' };
483
496
  if (options.withSemantic) {
484
- semantic = await enableSemantic({ projectRoot, spawnNpm: options.spawnNpm, warm: options.warmEmbedder });
497
+ semantic = await enableSemantic({
498
+ projectRoot,
499
+ spawnNpm: options.spawnNpm,
500
+ warm: options.warmEmbedder,
501
+ probeEmbedder: options.probeEmbedder,
502
+ });
485
503
  if (semantic.action === 'error') errors.push({ path: 'semantic', error: semantic.error });
486
504
  } else if (options.noSemantic) {
487
505
  const r = mergeProjectSettings(projectRoot, { search: { default_mode: 'keyword' } });
@@ -558,15 +576,43 @@ export function buildDefaultNpmRunner({ spawnSyncImpl = spawnSync } = {}) {
558
576
  };
559
577
  }
560
578
 
561
- async function enableSemantic({ projectRoot, spawnNpm, warm }) {
579
+ async function enableSemantic({ projectRoot, spawnNpm, warm, probeEmbedder }) {
562
580
  // 1. Install the optional embedder globally (it resolves as a sibling of
563
581
  // the globally-installed kit). Injectable for tests.
564
582
  const runNpm = spawnNpm ?? buildDefaultNpmRunner();
565
583
  const npm = runNpm();
566
- if (npm.status !== 0) {
584
+
585
+ // Task 170 (the v0.4.1 cut-gate find): gate on whether the embedder ACTUALLY
586
+ // IMPORTS, NOT on npm's exit code — verify the thing worked, not the command's
587
+ // exit (the D-199 class). Two failure modes the exit code gets wrong, BOTH
588
+ // sides:
589
+ // - npm exits NON-ZERO but the package installed fine: on Windows a benign
590
+ // cleanup-EBUSY (npm failing to unlink a leftover temp DLL still locked by
591
+ // a running process — sharp-win32-x64 / libvips) makes npm exit non-zero
592
+ // AFTER a successful install. Trusting the exit FALSELY reported "NOT
593
+ // enabled" while the embedder was present + importable (the live find).
594
+ // - npm exits ZERO but the import is BROKEN (partial/corrupt native module):
595
+ // trusting the exit would wrongly write a hybrid default with no working
596
+ // embedder — every search would degrade to the fallback warning (the
597
+ // half-state this function exists to avoid).
598
+ // So PROBE the import once, and enable hybrid IFF it imports — regardless of
599
+ // npm's exit. The install must be AUTOMATIC: the user does nothing and never
600
+ // needs a manual `cmk config set` to recover from a benign npm warning.
601
+ const probe = probeEmbedder ?? (async () => {
602
+ const { checkEmbedderBinding } = await import('./native-binding.mjs');
603
+ return checkEmbedderBinding();
604
+ });
605
+ let imported;
606
+ try {
607
+ imported = await probe();
608
+ } catch {
609
+ imported = { ok: false };
610
+ }
611
+ if (!imported?.ok) {
612
+ const detail = npm.status !== 0 ? (npm.error ?? `exit ${npm.status}`) : (imported?.reason ?? 'embedder import failed');
567
613
  return {
568
614
  action: 'error',
569
- error: `npm install -g @huggingface/transformers failed (${npm.error ?? `exit ${npm.status}`}) — semantic recall NOT enabled; keyword search is unaffected`,
615
+ error: `semantic embedder not usable after install (${detail}) — semantic recall NOT enabled; keyword search is unaffected`,
570
616
  };
571
617
  }
572
618
  // 2. Flip the project default to hybrid ONLY after the dependency landed