@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,366 @@
1
+ // kiro-transcript.mjs — the Kiro session-transcript adapter (Task 50.H).
2
+ //
3
+ // Resolves the D-180 "highest unverified risk": Kiro stores transcripts
4
+ // differently from Claude Code, and the kit's capture path hardcoded the
5
+ // Claude-Code touchpoints (~/.claude/projects/<slug>/<session>.jsonl, JSONL).
6
+ // Verified on a REAL Kiro install (D-180): Kiro is a VS Code fork; per-session
7
+ // JSON lives at
8
+ // %APPDATA%/Kiro/User/globalStorage/kiro.kiroagent/workspace-sessions/
9
+ // <base64url(workspacePath)>/<sessionId>.json
10
+ // with a `history[]` of { message: { role, content: [{type:'text', text}] } }
11
+ // plus a sibling `sessions.json` index.
12
+ //
13
+ // This module is the per-agent transcript adapter the cross-agent seam needs:
14
+ // it turns Kiro's session JSON into the {role, text} turns the kit's capture
15
+ // path consumes, and resolves the workspace→dir key. Pure + defensive — a
16
+ // malformed/partial session returns [] rather than throwing (a capture hook
17
+ // must never crash the agent).
18
+ //
19
+ // Public surface:
20
+ // parseKiroSessionHistory(jsonText) → [{role, text}] (ordered turns, IDE schema)
21
+ // parseKiroCliSession(jsonText) → {assistantText} (kiro-cli schema, D-199)
22
+ // readKiroCliTurn({projectRoot, env}) → {userText, assistantText} (~/.kiro/sessions/cli)
23
+ // workspaceKeyForPath(workspacePath) → string (the base64url dir key)
24
+ // parseKiroIdeV1Messages(jsonlText) → {userText, assistantText} (IDE 1.0 messages.jsonl)
25
+ // readKiroIdeV1Turn({projectRoot, env}) → {userText, assistantText} (~/.kiro/sessions/<hash>/sess_*)
26
+ // readKiroTurn({projectRoot, env}) → {userText, assistantText} (IDE-0.x → CLI → IDE-1.0)
27
+
28
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
29
+ import { homedir } from 'node:os';
30
+ import { join } from 'node:path';
31
+
32
+ /**
33
+ * Parse a Kiro session JSON into ordered {role, text} turns.
34
+ * @param {string} jsonText raw contents of a <sessionId>.json file
35
+ * @returns {{role:string, text:string}[]}
36
+ */
37
+ export function parseKiroSessionHistory(jsonText) {
38
+ let session;
39
+ try {
40
+ session = JSON.parse(jsonText);
41
+ } catch {
42
+ return [];
43
+ }
44
+ if (!session || !Array.isArray(session.history)) return [];
45
+
46
+ const turns = [];
47
+ for (const item of session.history) {
48
+ const msg = item && item.message;
49
+ if (!msg || typeof msg.role !== 'string') continue;
50
+ const text = extractText(msg.content);
51
+ if (text !== '') turns.push({ role: msg.role, text });
52
+ }
53
+ return turns;
54
+ }
55
+
56
+ /**
57
+ * Parse a kiro-cli session JSON into the latest assistant turn text.
58
+ *
59
+ * The kiro-CLI session schema is DIFFERENT from the IDE's (D-199 gate finding —
60
+ * primary-source verified on a real `~/.kiro/sessions/cli/<uuid>.json`): there is
61
+ * NO `history[]`. Instead the per-turn assistant text lives at
62
+ * session_state.conversation_metadata.user_turn_metadatas[].result.Ok.content[].data
63
+ * (each content part is `{ kind, data }`, `kind === 'text'`). The user's prompt
64
+ * text is NOT stored verbatim (only `user_prompt_length`), so the CLI path yields
65
+ * the ASSISTANT text only — which is all captureTurn's extractTurnText needs.
66
+ *
67
+ * @param {string} jsonText raw contents of a kiro-cli <uuid>.json file
68
+ * @returns {{assistantText:string}}
69
+ */
70
+ export function parseKiroCliSession(jsonText) {
71
+ let session;
72
+ try {
73
+ session = JSON.parse(jsonText);
74
+ } catch {
75
+ return { assistantText: '' };
76
+ }
77
+ const turns = session?.session_state?.conversation_metadata?.user_turn_metadatas;
78
+ if (!Array.isArray(turns) || turns.length === 0) return { assistantText: '' };
79
+
80
+ // the LAST turn is the most recent; read its assistant text from result.Ok.content[].
81
+ const last = turns[turns.length - 1];
82
+ const content = last?.result?.Ok?.content;
83
+ if (!Array.isArray(content)) return { assistantText: '' };
84
+ const assistantText = content
85
+ .filter((part) => part && part.kind === 'text' && typeof part.data === 'string')
86
+ .map((part) => part.data)
87
+ .join('\n');
88
+ return { assistantText };
89
+ }
90
+
91
+ /**
92
+ * Read the latest assistant turn from a kiro-CLI session for a project. The CLI
93
+ * stores sessions at ~/.kiro/sessions/cli/<uuid>.json, each carrying its own
94
+ * `cwd` + `updated_at`; we pick the most-recent file whose `cwd` matches the
95
+ * project root. Pure + defensive (a capture hook must never crash the session).
96
+ * @param {{projectRoot:string, env?:object}} args
97
+ * @returns {{userText:string, assistantText:string}}
98
+ */
99
+ export function readKiroCliTurn({ projectRoot, env = process.env } = {}) {
100
+ const empty = { userText: '', assistantText: '' };
101
+ try {
102
+ if (!projectRoot) return empty;
103
+ const home = env.USERPROFILE || env.HOME || homedir();
104
+ const cliDir = join(home, '.kiro', 'sessions', 'cli');
105
+ if (!existsSync(cliDir)) return empty;
106
+
107
+ // Compare cwd by NORMALIZED separators + case, NOT path.resolve(): both
108
+ // projectRoot and json.cwd are already absolute, same-machine, same-form
109
+ // paths (the hook's cwd and the session's recorded cwd). resolve() would
110
+ // re-root a value against the current cwd if it ever looked relative — a real
111
+ // hazard. ASSUMPTION: both sides are absolute drive-letter or matching-form
112
+ // paths; an extended-length (\\?\) or `~` form on one side only won't match
113
+ // (a missed match → empty capture, never a crash). Full-string equality, not
114
+ // a prefix test — so `C:\Temp\proj` and `C:\Temp\proj-2` correctly differ.
115
+ const norm = (p) => p.replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
116
+ const want = norm(projectRoot);
117
+ const files = readdirSync(cliDir).filter((f) => f.endsWith('.json'));
118
+ let best = null;
119
+ let bestStamp = null; // [epochMs, filename] for a deterministic tie-break
120
+ for (const f of files) {
121
+ let json;
122
+ try {
123
+ json = JSON.parse(readFileSync(join(cliDir, f), 'utf8'));
124
+ } catch {
125
+ continue;
126
+ }
127
+ if (!json?.cwd || norm(json.cwd) !== want) continue;
128
+ // Parse updated_at to epoch ms for a numeric compare — robust to ISO
129
+ // precision/offset drift (a future kiro-cli format change won't silently
130
+ // mis-order). Unparseable → 0, so the filename tie-break still orders it.
131
+ const epoch = Date.parse(json.updated_at) || 0;
132
+ const stamp = [epoch, f];
133
+ if (bestStamp === null || stamp[0] > bestStamp[0] || (stamp[0] === bestStamp[0] && stamp[1] > bestStamp[1])) {
134
+ bestStamp = stamp;
135
+ best = json;
136
+ }
137
+ }
138
+ if (!best) return empty;
139
+ const { assistantText } = parseKiroCliSession(JSON.stringify(best));
140
+ return { userText: '', assistantText };
141
+ } catch {
142
+ return empty;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Parse a Kiro IDE 1.0 messages.jsonl (D-203g) into the latest user + assistant
148
+ * text. IDE 1.0 moved session storage to ~/.kiro/sessions/<hash>/sess_<uuid>/ —
149
+ * a JSON-Lines `messages.jsonl`: one `{id, timestamp, payload:{type, content}}` per
150
+ * line; `payload.type ∈ {user, assistant, tool_call, tool_result, turn_start/end,
151
+ * ContextualHookInvoked, …}`; `payload.content` is the message text (string) for
152
+ * user/assistant. We take the LAST user + LAST assistant content. Primary-source
153
+ * verified on a real IDE-1.0 session.
154
+ * @param {string} jsonlText raw contents of a messages.jsonl
155
+ * @returns {{userText:string, assistantText:string}}
156
+ */
157
+ export function parseKiroIdeV1Messages(jsonlText) {
158
+ const empty = { userText: '', assistantText: '' };
159
+ if (typeof jsonlText !== 'string') return empty;
160
+ let userText = '';
161
+ let assistantText = '';
162
+ for (const line of jsonlText.split('\n')) {
163
+ if (line.trim() === '') continue;
164
+ let msg;
165
+ try {
166
+ msg = JSON.parse(line);
167
+ } catch {
168
+ continue; // a malformed line never crashes the whole read
169
+ }
170
+ const type = msg?.payload?.type;
171
+ if (type !== 'user' && type !== 'assistant') continue;
172
+ const text = ideV1ContentText(msg?.payload?.content);
173
+ if (text === '') continue;
174
+ if (type === 'user') userText = text; // keep the LAST one
175
+ else assistantText = text;
176
+ }
177
+ return { userText, assistantText };
178
+ }
179
+
180
+ // IDE 1.0 payload.content is a STRING for plain messages (verified on a real
181
+ // session), but a multi-part message (with images/documents) MAY serialize it as
182
+ // an array of typed parts like IDE-0.x — so handle BOTH (review I1): a string is
183
+ // returned as-is; an array joins the `text`-typed parts (the `{type:'text', text}`
184
+ // shape, like extractText). Anything else → '' (drop, never crash). Defensive so a
185
+ // multi-part assistant reply isn't silently dropped → capture-nothing recurrence.
186
+ function ideV1ContentText(content) {
187
+ if (typeof content === 'string') return content;
188
+ if (Array.isArray(content)) {
189
+ return content
190
+ .filter((part) => part && part.type === 'text' && typeof part.text === 'string')
191
+ .map((part) => part.text)
192
+ .join('\n');
193
+ }
194
+ return '';
195
+ }
196
+
197
+ /**
198
+ * Read the latest turn from a Kiro IDE 1.0 session (D-203g). Scans
199
+ * ~/.kiro/sessions/<workspace-hash>/sess_<uuid>/, matching session.json's
200
+ * `workspacePaths` to projectRoot (no hash-reversing), picking the most-recently-
201
+ * modified messages.jsonl. Pure + defensive (a capture hook must never crash).
202
+ * @param {{projectRoot:string, env?:object}} args
203
+ * @returns {{userText:string, assistantText:string}}
204
+ */
205
+ export function readKiroIdeV1Turn({ projectRoot, env = process.env } = {}) {
206
+ const empty = { userText: '', assistantText: '' };
207
+ try {
208
+ if (!projectRoot) return empty;
209
+ const home = env.USERPROFILE || env.HOME || homedir();
210
+ const sessionsRoot = join(home, '.kiro', 'sessions');
211
+ if (!existsSync(sessionsRoot)) return empty;
212
+
213
+ const norm = (p) => p.replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
214
+ const want = norm(projectRoot);
215
+
216
+ // walk <hash>/sess_*/ dirs; match by session.json.workspacePaths; pick latest
217
+ // messages.jsonl by mtime, tie-broken by path (review M1 — mirror the CLI/0.x
218
+ // readers' deterministic [stamp, path] tie-break so an equal mtime isn't
219
+ // decided by readdir order).
220
+ let best = null; // [mtimeMs, messagesPath]
221
+ for (const hash of readdirSync(sessionsRoot)) {
222
+ const hashDir = join(sessionsRoot, hash);
223
+ let sessDirs;
224
+ try {
225
+ sessDirs = readdirSync(hashDir).filter((d) => d.startsWith('sess_'));
226
+ } catch {
227
+ // a non-directory entry at sessions/ root → readdirSync throws ENOTDIR;
228
+ // skip it. (The `cli/` sibling IS a dir but has no sess_* children, so it
229
+ // falls out of the filter below — both cases are handled, never crash.)
230
+ continue;
231
+ }
232
+ for (const sd of sessDirs) {
233
+ const dir = join(hashDir, sd);
234
+ const metaPath = join(dir, 'session.json');
235
+ const msgPath = join(dir, 'messages.jsonl');
236
+ if (!existsSync(metaPath) || !existsSync(msgPath)) continue;
237
+ let meta;
238
+ try {
239
+ meta = JSON.parse(readFileSync(metaPath, 'utf8'));
240
+ } catch {
241
+ continue;
242
+ }
243
+ const paths = Array.isArray(meta?.workspacePaths) ? meta.workspacePaths : [];
244
+ if (!paths.some((p) => typeof p === 'string' && norm(p) === want)) continue;
245
+ let mtimeMs = 0;
246
+ try {
247
+ mtimeMs = statSync(msgPath).mtimeMs;
248
+ } catch {
249
+ /* keep 0 */
250
+ }
251
+ if (
252
+ best === null ||
253
+ mtimeMs > best[0] ||
254
+ (mtimeMs === best[0] && msgPath > best[1]) // deterministic tie-break (M1)
255
+ ) {
256
+ best = [mtimeMs, msgPath];
257
+ }
258
+ }
259
+ }
260
+ if (!best) return empty;
261
+ return parseKiroIdeV1Messages(readFileSync(best[1], 'utf8'));
262
+ } catch {
263
+ return empty;
264
+ }
265
+ }
266
+
267
+ // Join the text of all text-type content parts; ignore tool/other blocks.
268
+ // Kiro's content is always an array of typed parts (verified on a real install);
269
+ // a non-array is treated as "no text" rather than guessed at.
270
+ function extractText(content) {
271
+ if (!Array.isArray(content)) return '';
272
+ return content
273
+ .filter((part) => part && part.type === 'text' && typeof part.text === 'string')
274
+ .map((part) => part.text)
275
+ .join('\n');
276
+ }
277
+
278
+ /**
279
+ * Encode a workspace path to Kiro's workspace-sessions directory key.
280
+ * EXACT scheme verified on a real install: standard base64, then +→-, /→_, and
281
+ * the `=` padding → `_` (NOT stripped — Kiro keeps the padding as underscores).
282
+ * @param {string} workspacePath e.g. 'c:\\Projects\\demo'
283
+ * @returns {string}
284
+ */
285
+ export function workspaceKeyForPath(workspacePath) {
286
+ return Buffer.from(workspacePath, 'utf8')
287
+ .toString('base64')
288
+ .replace(/\+/g, '-')
289
+ .replace(/\//g, '_')
290
+ .replace(/=/g, '_');
291
+ }
292
+
293
+ /**
294
+ * Read the latest user+assistant turn from Kiro's transcript for a project.
295
+ *
296
+ * Composes the verified pieces (probe P-CJYGTQYR + D-180):
297
+ * - the globalStorage dir is given to the hook via env CONTINUE_GLOBAL_DIR
298
+ * (= …/Kiro/User/globalStorage/kiro.kiroagent), so we don't guess the path;
299
+ * - the per-project dir is workspace-sessions/<base64url(projectRoot)>;
300
+ * - the most-recent <sessionId>.json (by dateCreated, else mtime) is the live
301
+ * session; its history[] is parsed to the latest user + assistant text.
302
+ *
303
+ * Pure + defensive: any missing dir / absent env / parse error returns empty
304
+ * strings, never throws (a capture hook must never crash the Kiro session).
305
+ *
306
+ * @param {{projectRoot:string, env?:object}} args
307
+ * @returns {{userText:string, assistantText:string}}
308
+ */
309
+ // The non-(IDE-0.x) fallback chain: kiro-CLI (~/.kiro/sessions/cli, D-199) → IDE
310
+ // 1.0 (~/.kiro/sessions/<hash>/sess_*/messages.jsonl, D-203g). Tried when the
311
+ // legacy IDE-0.x globalStorage path yields nothing. Returns the first non-empty.
312
+ function readKiroFallbackTurn({ projectRoot, env }) {
313
+ const cli = readKiroCliTurn({ projectRoot, env });
314
+ if (cli.assistantText || cli.userText) return cli;
315
+ return readKiroIdeV1Turn({ projectRoot, env });
316
+ }
317
+
318
+ export function readKiroTurn({ projectRoot, env = process.env } = {}) {
319
+ const empty = { userText: '', assistantText: '' };
320
+ try {
321
+ const globalDir = env.CONTINUE_GLOBAL_DIR;
322
+ // No IDE-0.x globalStorage (the CLI/IDE-1.0 don't set CONTINUE_GLOBAL_DIR) →
323
+ // the fallback chain (kiro-CLI then IDE-1.0). The legacy IDE-0.x path stays
324
+ // PRIMARY when its env+dir are present, so an 0.x user is unaffected.
325
+ if (!globalDir || !projectRoot) return readKiroFallbackTurn({ projectRoot, env });
326
+ const wsDir = join(globalDir, 'workspace-sessions', workspaceKeyForPath(projectRoot));
327
+ if (!existsSync(wsDir)) return readKiroFallbackTurn({ projectRoot, env });
328
+
329
+ // pick the most-recent session file (by dateCreated in the JSON, else mtime).
330
+ const files = readdirSync(wsDir).filter((f) => f.endsWith('.json') && f !== 'sessions.json');
331
+ if (files.length === 0) return readKiroFallbackTurn({ projectRoot, env });
332
+
333
+ // Sort files for a DETERMINISTIC pick (review M2): primary by dateCreated
334
+ // (desc), secondary by filename (desc) so an equal-stamp tie isn't decided by
335
+ // readdir order. The first after sort is the most-recent session.
336
+ let best = null;
337
+ let bestKey = null; // [stamp, filename]
338
+ for (const f of files) {
339
+ let json;
340
+ try {
341
+ json = JSON.parse(readFileSync(join(wsDir, f), 'utf8'));
342
+ } catch {
343
+ continue;
344
+ }
345
+ const key = [Number(json.dateCreated) || 0, f];
346
+ if (bestKey === null || key[0] > bestKey[0] || (key[0] === bestKey[0] && key[1] > bestKey[1])) {
347
+ bestKey = key;
348
+ best = json;
349
+ }
350
+ }
351
+ if (!best || !Array.isArray(best.history)) return readKiroFallbackTurn({ projectRoot, env });
352
+
353
+ const turns = parseKiroSessionHistory(JSON.stringify(best));
354
+ const lastUser = [...turns].reverse().find((t) => t.role === 'user');
355
+ const lastAssistant = [...turns].reverse().find((t) => t.role === 'assistant');
356
+ // IDE-0.x session present but no text → fall through (a mixed install where the
357
+ // same project was used from another surface).
358
+ if (!lastAssistant?.text && !lastUser?.text) return readKiroFallbackTurn({ projectRoot, env });
359
+ return {
360
+ userText: lastUser?.text || '',
361
+ assistantText: lastAssistant?.text || '',
362
+ };
363
+ } catch {
364
+ return empty;
365
+ }
366
+ }
@@ -0,0 +1,130 @@
1
+ // kiro-trusted-commands.mjs — pre-trust the kit's Kiro hook commands (D-194).
2
+ //
3
+ // THE PROBLEM (found live in the v0.4.0 cut-gate-kiro, 50.M): Kiro gates a hook's
4
+ // shell command behind a "Run / Reject" approval prompt unless it's pre-trusted
5
+ // (kiro.dev/docs/cli/chat/permissions). So the kit's inject/capture/guard hooks —
6
+ // `cmd.exe /c cmk hook promptSubmit`, etc. — prompt the user EVERY turn, and
7
+ // "automatic memory" isn't automatic. On Claude Code a registered hook just fires;
8
+ // on Kiro it must be trusted (the 6th cross-agent "Claude-Code-shaped assumption"
9
+ // cut-blocker — D-185/186/187/188/190).
10
+ //
11
+ // THE FIX: write the kit's OWN hook-command prefixes into the WORKSPACE
12
+ // `.vscode/settings.json` under `kiroAgent.trustedCommands` — Kiro's IDE
13
+ // command-trust list (an array of wildcard-PREFIX patterns; `npm *` trusts any
14
+ // command starting `npm `). Workspace scope (not the user-global
15
+ // `…/Kiro/User/settings.json`) so the trust travels with the repo and never
16
+ // touches the user's machine-wide trust.
17
+ //
18
+ // We trust ONLY the kit's own commands by SPECIFIC prefix — never the
19
+ // over-permissive `cmd.exe /c *` or `*` (the docs warn wildcards over-trust, and
20
+ // trust matches only the command PREFIX, so a broad prefix would also trust any
21
+ // chained command after it).
22
+ //
23
+ // Disciplines (same as mutateAgentConfig): array-UNION (a user's existing trusted
24
+ // commands are preserved + deduped, never clobbered), refuse-to-clobber on a
25
+ // corrupt file, BOM-tolerant read (D-187), idempotent, atomic write. Uninstall
26
+ // removes ONLY our patterns and prunes an emptied key (no orphan empty array) —
27
+ // the over-mutation guard.
28
+ //
29
+ // Public surface:
30
+ // installKiroTrustedCommands({ projectRoot }) → { action, changed, path }
31
+ // uninstallKiroTrustedCommands({ projectRoot }) → { action, changed, path }
32
+ // kitTrustedCommandPatterns() → string[] (the patterns we own — also the
33
+ // uninstall key + the doctor/gate check)
34
+
35
+ import { existsSync, readFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { parseJsonFile } from './read-json.mjs';
38
+ import { atomicWrite } from './mutate-agent-config.mjs';
39
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
40
+
41
+ const SETTINGS_PATH = ['.vscode', 'settings.json'];
42
+ const TRUSTED_KEY = 'kiroAgent.trustedCommands';
43
+
44
+ const IS_WINDOWS = process.platform === 'win32';
45
+
46
+ // The kit's hook commands, as TRUST PREFIXES. These must prefix-match the actual
47
+ // commands kiro-hook-command.mjs emits:
48
+ // IDE/CLI hooks → `[cmd.exe /c ]cmk hook <event>` → `…cmk hook *`
49
+ // delete-guard → `[cmd.exe /c ]cmk-guard-memory` → `…cmk-guard-memory*`
50
+ // Windows wraps in `cmd.exe /c ` (the WSL-no-node finding, P-PM2CD6CB); POSIX runs
51
+ // the bare command. We keep these in lockstep with kiro-hook-command.mjs by
52
+ // mirroring its exact platform prefix — a SPECIFIC prefix, not a blanket wildcard.
53
+ const WIN_PREFIX = 'cmd.exe /c ';
54
+ export function kitTrustedCommandPatterns() {
55
+ const base = ['cmk hook *', 'cmk-guard-memory*'];
56
+ return IS_WINDOWS ? base.map((b) => WIN_PREFIX + b) : base;
57
+ }
58
+
59
+ // Read settings.json, distinguishing missing (→ {}) from corrupt (→ throw-marker).
60
+ // BOM-tolerant: a Windows-editor BOM must not read as corrupt (D-187). Returns
61
+ // { root } on success or { error } on a genuine parse failure (refuse-to-clobber).
62
+ function readSettings(path) {
63
+ if (!existsSync(path)) return { root: {} };
64
+ let raw;
65
+ try {
66
+ raw = readFileSync(path, 'utf8');
67
+ } catch (err) {
68
+ return { error: `could not read ${path}: ${err.message}` };
69
+ }
70
+ if (raw.trim() === '') return { root: {} };
71
+ // parseJsonFile strips the BOM and returns the sentinel on bad JSON.
72
+ const CORRUPT = Symbol('corrupt');
73
+ const parsed = parseJsonFile(path, { fallback: CORRUPT });
74
+ if (parsed === CORRUPT) return { error: `${path} is not valid JSON — refusing to overwrite` };
75
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
76
+ return { error: `${path} is valid JSON but not an object — refusing to overwrite` };
77
+ }
78
+ return { root: parsed };
79
+ }
80
+
81
+ export function installKiroTrustedCommands({ projectRoot } = {}) {
82
+ if (!projectRoot) throw new Error('installKiroTrustedCommands: projectRoot is required');
83
+ const path = join(projectRoot, ...SETTINGS_PATH);
84
+ const fileExists = existsSync(path);
85
+
86
+ const { root, error } = readSettings(path);
87
+ if (error) return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [error], changed: false, path });
88
+
89
+ const existing = Array.isArray(root[TRUSTED_KEY]) ? root[TRUSTED_KEY] : [];
90
+ const want = kitTrustedCommandPatterns();
91
+
92
+ // array-UNION: keep the user's entries (and order), append only the kit
93
+ // patterns not already present. Idempotent: if all ours are present, no write.
94
+ const missing = want.filter((p) => !existing.includes(p));
95
+ if (missing.length === 0) return { action: 'skipped', changed: false, path };
96
+
97
+ const next = { ...root, [TRUSTED_KEY]: [...existing, ...missing] };
98
+ atomicWrite(path, `${JSON.stringify(next, null, 2)}\n`);
99
+ return { action: 'installed', changed: true, path, created: !fileExists };
100
+ }
101
+
102
+ export function uninstallKiroTrustedCommands({ projectRoot } = {}) {
103
+ if (!projectRoot) throw new Error('uninstallKiroTrustedCommands: projectRoot is required');
104
+ const path = join(projectRoot, ...SETTINGS_PATH);
105
+ if (!existsSync(path)) return { action: 'noop', changed: false, path };
106
+
107
+ const { root, error } = readSettings(path);
108
+ // A corrupt file on uninstall: leave it alone (don't error-out the whole
109
+ // uninstall; just report no change for this leg).
110
+ if (error || !Array.isArray(root[TRUSTED_KEY])) return { action: 'noop', changed: false, path };
111
+
112
+ // Ownership is by exact-string membership (skill-review M1): if a user had
113
+ // MANUALLY added a pattern byte-identical to one of ours, uninstall removes it
114
+ // too — we can't distinguish who added an identical string without a separate
115
+ // ownership-marker array, which would be over-engineering for this surface.
116
+ // Collision is near-zero (our patterns are kit-specific: `cmd.exe /c cmk hook *`).
117
+ const ours = new Set(kitTrustedCommandPatterns());
118
+ const kept = root[TRUSTED_KEY].filter((c) => !ours.has(c));
119
+ if (kept.length === root[TRUSTED_KEY].length) return { action: 'noop', changed: false, path };
120
+
121
+ const next = { ...root };
122
+ if (kept.length === 0) {
123
+ // prune an emptied key — no orphan empty array left behind.
124
+ delete next[TRUSTED_KEY];
125
+ } else {
126
+ next[TRUSTED_KEY] = kept;
127
+ }
128
+ atomicWrite(path, `${JSON.stringify(next, null, 2)}\n`);
129
+ return { action: 'uninstalled', changed: true, path };
130
+ }