@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.
- package/README.md +137 -50
- package/bin/cmk-approve-permission.mjs +62 -0
- package/bin/cmk-daily-distill.mjs +14 -0
- package/bin/cmk-guard-memory.mjs +57 -0
- package/bin/cmk-inject-context.mjs +12 -0
- package/bin/cmk-weekly-curate.mjs +12 -0
- package/package.json +4 -2
- package/src/agent-profile.mjs +115 -0
- package/src/agent-profiles.mjs +118 -0
- package/src/approve-permission.mjs +92 -0
- package/src/auto-extract.mjs +17 -10
- package/src/auto-persona.mjs +11 -4
- package/src/compaction-state.mjs +204 -0
- package/src/compress-session.mjs +13 -1
- package/src/config-core.mjs +7 -9
- package/src/decisions-journal.mjs +71 -3
- package/src/doctor.mjs +128 -5
- package/src/guard-memory.mjs +151 -0
- package/src/import-anthropic-memory.mjs +15 -1
- package/src/inject-context.mjs +42 -18
- package/src/install-agent.mjs +220 -0
- package/src/install-kiro.mjs +287 -0
- package/src/install.mjs +53 -7
- 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 +43 -110
- 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/register-crons.mjs +31 -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 +70 -3
- package/src/subcommands.mjs +360 -27
- package/src/tier-paths.mjs +82 -1
- package/src/weekly-curate.mjs +6 -2
- 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
|
@@ -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
|
+
}
|