@lh8ppl/claude-memory-kit 0.3.5 → 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.
Files changed (44) hide show
  1. package/README.md +6 -0
  2. package/bin/cmk-guard-memory.mjs +57 -0
  3. package/package.json +3 -2
  4. package/src/agent-profile.mjs +115 -0
  5. package/src/agent-profiles.mjs +118 -0
  6. package/src/auto-persona.mjs +4 -1
  7. package/src/compress-session.mjs +13 -1
  8. package/src/config-core.mjs +7 -9
  9. package/src/decisions-journal.mjs +71 -3
  10. package/src/doctor.mjs +86 -4
  11. package/src/guard-memory.mjs +151 -0
  12. package/src/import-anthropic-memory.mjs +15 -1
  13. package/src/inject-context.mjs +34 -3
  14. package/src/install-agent.mjs +220 -0
  15. package/src/install-kiro.mjs +287 -0
  16. package/src/install.mjs +16 -3
  17. package/src/kiro-cli-agent.mjs +270 -0
  18. package/src/kiro-constants.mjs +19 -0
  19. package/src/kiro-hook-bin.mjs +105 -0
  20. package/src/kiro-hook-command.mjs +67 -0
  21. package/src/kiro-hook-dispatch.mjs +115 -0
  22. package/src/kiro-ide-hooks.mjs +219 -0
  23. package/src/kiro-permissions.mjs +175 -0
  24. package/src/kiro-skills.mjs +96 -0
  25. package/src/kiro-transcript.mjs +366 -0
  26. package/src/kiro-trusted-commands.mjs +130 -0
  27. package/src/managed-block.mjs +138 -0
  28. package/src/memory-write.mjs +23 -8
  29. package/src/mutate-agent-config.mjs +243 -0
  30. package/src/read-json.mjs +43 -0
  31. package/src/reindex.mjs +15 -2
  32. package/src/repair.mjs +39 -3
  33. package/src/result-shapes.mjs +8 -0
  34. package/src/review-queue.mjs +3 -0
  35. package/src/scratchpad.mjs +12 -2
  36. package/src/search.mjs +12 -5
  37. package/src/semantic-backend.mjs +7 -9
  38. package/src/settings-hooks.mjs +12 -2
  39. package/src/subcommands.mjs +360 -27
  40. package/src/tier-paths.mjs +48 -1
  41. package/src/weekly-curate.mjs +6 -2
  42. package/template/.claude/skills/memory-search/SKILL.md +14 -1
  43. package/template/.claude/skills/memory-write/SKILL.md +37 -1
  44. package/template/project/memory/INDEX.md.template +1 -1
@@ -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
+ }
@@ -0,0 +1,138 @@
1
+ // managed-block.mjs — shared helpers for agent-config + instruction-file writes.
2
+ //
3
+ // install-agent.mjs (the generic per-agent installer) and install-kiro.mjs (the
4
+ // Kiro 4-surface orchestrator) both need the same primitives: a managed
5
+ // marker-block instruction-file writer, JSON-key removal that preserves siblings,
6
+ // empty-parent pruning, and the non-regex newline trims (ReDoS-safe). These were
7
+ // duplicated verbatim across the two; centralizing them here removes the drift
8
+ // hazard (the kit's shared-module discipline — the same lesson the duplicated
9
+ // atomicWrite taught earlier in Task 50).
10
+ //
11
+ // Public surface:
12
+ // writeManagedBlock(path, {body, frontmatter, markStart, markEnd}) → changed:bool
13
+ // removeManagedBlock(path, {markStart, markEnd}) → changed:bool
14
+ // removeJsonKey(path, keyPath) → changed:bool (preserves siblings)
15
+ // pruneEmptyParent(path, keyPath) → void (drops an emptied {} we leave)
16
+ // escapeRe / trimLeadingNewlines / trimTrailingNewlines (ReDoS-safe utils)
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { atomicWrite } from './mutate-agent-config.mjs';
20
+
21
+ export const DEFAULT_MARK_START = '<!-- claude-memory-kit:start -->';
22
+ export const DEFAULT_MARK_END = '<!-- claude-memory-kit:end -->';
23
+
24
+ /**
25
+ * Write/refresh a managed marker block in an instruction file, byte-preserving
26
+ * everything outside the markers. `frontmatter` (e.g. Kiro's `inclusion: always`)
27
+ * is prepended only when the file is created fresh. Idempotent: identical content
28
+ * → no write, returns false.
29
+ * @returns {boolean} whether a write happened.
30
+ */
31
+ export function writeManagedBlock(path, { body, frontmatter = '', markStart = DEFAULT_MARK_START, markEnd = DEFAULT_MARK_END } = {}) {
32
+ const block = `${markStart}\n${body}\n${markEnd}`;
33
+ const desired = `${frontmatter}${block}\n`;
34
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
35
+
36
+ let next;
37
+ if (existing === '') {
38
+ next = desired;
39
+ } else if (existing.includes(markStart) && existing.includes(markEnd)) {
40
+ // refresh in place — replace only the managed block, byte-preserve the rest.
41
+ next = existing.replace(
42
+ new RegExp(`${escapeRe(markStart)}[\\s\\S]*?${escapeRe(markEnd)}`),
43
+ block,
44
+ );
45
+ } else {
46
+ // append our block to the user's existing file.
47
+ next = `${trimTrailingNewlines(existing)}\n\n${block}\n`;
48
+ }
49
+
50
+ if (next === existing) return false;
51
+ atomicWrite(path, next);
52
+ return true;
53
+ }
54
+
55
+ /** Strip our managed block (+ surrounding blank lines), byte-preserve the rest. */
56
+ export function removeManagedBlock(path, { markStart = DEFAULT_MARK_START, markEnd = DEFAULT_MARK_END } = {}) {
57
+ if (!existsSync(path)) return false;
58
+ const existing = readFileSync(path, 'utf8');
59
+ if (!existing.includes(markStart)) return false;
60
+ // The inner [\s\S]*? is lazy + bounded by two fixed literal delimiters → linear,
61
+ // not ReDoS-prone. Newline trims are non-regex (no `\n*$`/`^\n+` super-linear
62
+ // shapes); the `\n{3,}` collapse is a bounded quantifier, also linear.
63
+ const blockRe = new RegExp(`${escapeRe(markStart)}[\\s\\S]*?${escapeRe(markEnd)}`);
64
+ const stripped = trimLeadingNewlines(existing.replace(blockRe, '').replace(/\n{3,}/g, '\n\n'));
65
+ atomicWrite(path, stripped);
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Remove a nested key from a JSON file, preserving everything else. Skips
71
+ * (returns false) on a missing file / parse error (never clobbers a corrupt file).
72
+ * @returns {boolean} whether a change was written.
73
+ */
74
+ export function removeJsonKey(path, keyPath) {
75
+ if (!existsSync(path)) return false;
76
+ let root;
77
+ try {
78
+ root = JSON.parse(readFileSync(path, 'utf8'));
79
+ } catch {
80
+ return false;
81
+ }
82
+ let cur = root;
83
+ for (let i = 0; i < keyPath.length - 1; i += 1) {
84
+ if (!cur || typeof cur !== 'object' || !(keyPath[i] in cur)) return false;
85
+ cur = cur[keyPath[i]];
86
+ }
87
+ const last = keyPath[keyPath.length - 1];
88
+ if (!cur || typeof cur !== 'object' || !(last in cur)) return false;
89
+ delete cur[last];
90
+ atomicWrite(path, `${JSON.stringify(root, null, 2)}\n`);
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * If the object at keyPath exists and is now empty ({}), remove it — so an
96
+ * uninstall doesn't leave a kit-shaped `{"mcpServers":{}}` husk. No-op on a
97
+ * missing file / parse error / non-empty target.
98
+ */
99
+ export function pruneEmptyParent(path, keyPath) {
100
+ if (!existsSync(path)) return;
101
+ let root;
102
+ try {
103
+ root = JSON.parse(readFileSync(path, 'utf8'));
104
+ } catch {
105
+ return;
106
+ }
107
+ let cur = root;
108
+ for (let i = 0; i < keyPath.length - 1; i += 1) {
109
+ if (!cur || typeof cur !== 'object' || !(keyPath[i] in cur)) return;
110
+ cur = cur[keyPath[i]];
111
+ }
112
+ const last = keyPath[keyPath.length - 1];
113
+ const target = cur && typeof cur === 'object' ? cur[last] : undefined;
114
+ if (target && typeof target === 'object' && !Array.isArray(target) && Object.keys(target).length === 0) {
115
+ delete cur[last];
116
+ atomicWrite(path, `${JSON.stringify(root, null, 2)}\n`);
117
+ }
118
+ }
119
+
120
+ // ── ReDoS-safe string utils ──────────────────────────────────────────────────
121
+
122
+ export function escapeRe(s) {
123
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
124
+ }
125
+
126
+ // Non-regex newline trims — avoid the `\n*$` / `^\n+` super-linear-backtracking
127
+ // shapes a static analyzer (rightly) flags as ReDoS-prone. O(n), no backtracking.
128
+ export function trimTrailingNewlines(s) {
129
+ let end = s.length;
130
+ while (end > 0 && s[end - 1] === '\n') end -= 1;
131
+ return s.slice(0, end);
132
+ }
133
+
134
+ export function trimLeadingNewlines(s) {
135
+ let start = 0;
136
+ while (start < s.length && s[start] === '\n') start += 1;
137
+ return s.slice(start);
138
+ }
@@ -203,6 +203,27 @@ function findMatchingBullet({ lines, substring, sectionTitle }) {
203
203
  return null;
204
204
  }
205
205
 
206
+ // Strip a bullet + its provenance comment (lines bulletIdx..commentIdx) and
207
+ // collapse a resulting double blank line at the seam, so removing a bullet from
208
+ // a blank-padded scratchpad doesn't leave two adjacent blank lines (MD012
209
+ // no-multiple-blanks). Only the seam is touched — blanks elsewhere are
210
+ // preserved. The bullet↔comment pair is removed together (the adjacency
211
+ // invariant the whole kit depends on).
212
+ function stripBulletPair(lines, bulletIdx, commentIdx) {
213
+ const kept = [...lines.slice(0, bulletIdx), ...lines.slice(commentIdx + 1)];
214
+ // If the splice put a blank line directly after a blank line at the seam,
215
+ // drop one. The seam is at index `bulletIdx` in `kept`.
216
+ if (
217
+ bulletIdx > 0 &&
218
+ bulletIdx < kept.length &&
219
+ kept[bulletIdx - 1].trim() === '' &&
220
+ kept[bulletIdx].trim() === ''
221
+ ) {
222
+ kept.splice(bulletIdx, 1);
223
+ }
224
+ return kept.join('\n');
225
+ }
226
+
206
227
  // --- Tombstone writer (design §6.5) --------------------------------
207
228
 
208
229
  function writeTombstone({
@@ -438,10 +459,7 @@ function doReplace(opts) {
438
459
  });
439
460
  }
440
461
  // Strip the matched bullet + provenance comment.
441
- const stripped = [
442
- ...lines.slice(0, match.bulletIdx),
443
- ...lines.slice(match.commentIdx + 1),
444
- ].join('\n');
462
+ const stripped = stripBulletPair(lines, match.bulletIdx, match.commentIdx);
445
463
  writeFileSync(path, stripped, 'utf8');
446
464
 
447
465
  // Append the new bullet via the GUARDED inner path. We already ran
@@ -550,10 +568,7 @@ function doRemove(opts) {
550
568
  });
551
569
 
552
570
  // Strip from scratchpad.
553
- const stripped = [
554
- ...lines.slice(0, match.bulletIdx),
555
- ...lines.slice(match.commentIdx + 1),
556
- ].join('\n');
571
+ const stripped = stripBulletPair(lines, match.bulletIdx, match.commentIdx);
557
572
  writeFileSync(path, stripped, 'utf8');
558
573
 
559
574
  appendAuditEntry(tierRoot, {
@@ -0,0 +1,243 @@
1
+ // mutate-agent-config.mjs — the shared per-agent config-write PRIMITIVE.
2
+ //
3
+ // Task 50 (cross-agent install). This is the one piece of machinery the whole
4
+ // `cmk install --ide <agent>` seam rests on: a single, tested function that
5
+ // writes the kit's entry (an MCP-server registration, a hook entry) into ANY
6
+ // agent's config file WITHOUT clobbering the user's other keys.
7
+ //
8
+ // The D-180 finding (research note 2026-06-20): do NOT build a per-agent
9
+ // `Installer` base class — claude-mem proved it breaks the moment agents differ
10
+ // in format/mechanism, and its ~6 bespoke config writers drifted in rigor (one
11
+ // surgical, one whole-file-clobber, one that discards user config on a JSON
12
+ // parse error). What actually generalizes is THIS primitive. Each per-agent
13
+ // profile is then just DATA (where the file is, which key, which format).
14
+ //
15
+ // The kit's existing disciplines, applied to third-party files:
16
+ // - touch-only-our-keys = the marker-block byte-preservation invariant
17
+ // - refuse-on-parse-error = the safe-write / Poison_Guard fail-closed rule
18
+ // - atomic (tmp + rename) = the same pattern compress-session/persona use
19
+ // - idempotent changed-bool = re-running install is a no-op (Codex's pattern)
20
+ //
21
+ // Public surface (one function — a deep module, narrow interface):
22
+ //
23
+ // mutateAgentConfig({ path, format, keyPath, entry, mode? }) → result
24
+ //
25
+ // path absolute path to the agent's config file (may not exist yet)
26
+ // format 'json' (v0.4.0; 'yaml' / 'toml' deferred until an agent needs them)
27
+ // keyPath array of keys to the slot we own, e.g. ['mcpServers', 'claude-memory-kit']
28
+ // entry the object to place at keyPath
29
+ // mode 'merge' (default — deep-merge into an existing entry) | 'replace'
30
+ //
31
+ // Result shape (result-shapes.mjs conventions):
32
+ // { action: 'created'|'updated'|'skipped'|'error', changed: boolean, path,
33
+ // errorCategory?, errors? }
34
+ // created — the file did not exist; we created it with just our key
35
+ // updated — the file existed; our key was added/changed (siblings preserved)
36
+ // skipped — our key already matched exactly; nothing written (changed:false)
37
+ // error — input invalid (schema) or target unparseable (config_parse);
38
+ // NEVER writes on error
39
+
40
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
41
+ import { dirname, basename, join } from 'node:path';
42
+ import { randomUUID } from 'node:crypto';
43
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
44
+ import { stripBom } from './read-json.mjs';
45
+
46
+ const SUPPORTED_FORMATS = new Set(['json']);
47
+
48
+ /**
49
+ * Write the kit's entry into an agent config file, preserving everything else.
50
+ * @returns {{action:string, changed:boolean, path:string, errorCategory?:string, errors?:string[]}}
51
+ */
52
+ export function mutateAgentConfig({ path, format, keyPath, entry, mode = 'merge' }) {
53
+ // ── input validation (schema) ───────────────────────────────────────────
54
+ if (typeof path !== 'string' || path.length === 0) {
55
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: ['path must be a non-empty string'], changed: false });
56
+ }
57
+ if (!SUPPORTED_FORMATS.has(format)) {
58
+ return errorResult({
59
+ category: ERROR_CATEGORIES.SCHEMA,
60
+ errors: [`unsupported format ${JSON.stringify(format)} — supported: ${[...SUPPORTED_FORMATS].join(', ')}`],
61
+ changed: false,
62
+ path,
63
+ });
64
+ }
65
+ if (!Array.isArray(keyPath) || keyPath.length === 0 || !keyPath.every((k) => typeof k === 'string' && k.length > 0)) {
66
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: ['keyPath must be a non-empty array of non-empty strings'], changed: false, path });
67
+ }
68
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
69
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: ['entry must be a plain object'], changed: false, path });
70
+ }
71
+ if (mode !== 'merge' && mode !== 'replace') {
72
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors: [`mode must be 'merge' or 'replace', got ${JSON.stringify(mode)}`], changed: false, path });
73
+ }
74
+
75
+ // ── read existing (refuse-to-clobber on parse error) ─────────────────────
76
+ const fileExists = existsSync(path);
77
+ let root;
78
+ if (fileExists) {
79
+ let raw;
80
+ try {
81
+ // stripBom: a user's config file written by a Windows editor may carry a
82
+ // leading UTF-8 BOM, which would otherwise make the JSON.parse below refuse
83
+ // a perfectly valid file as "corrupt" (D-187). Strip only the BOM; the
84
+ // refuse-to-clobber-on-real-corruption guarantee below is unchanged.
85
+ raw = stripBom(readFileSync(path, 'utf8'));
86
+ } catch (err) {
87
+ return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [`could not read ${path}: ${err.message}`], changed: false, path });
88
+ }
89
+ if (raw.trim() === '') {
90
+ // an empty file is treated as an empty object (not a parse error)
91
+ root = {};
92
+ } else {
93
+ try {
94
+ root = JSON.parse(raw);
95
+ } catch (err) {
96
+ // The crucial guarantee: a corrupt target is NEVER overwritten.
97
+ return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [`${path} is not valid JSON — refusing to overwrite: ${err.message}`], changed: false, path });
98
+ }
99
+ }
100
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
101
+ return errorResult({ category: ERROR_CATEGORIES.CONFIG_PARSE, errors: [`${path} is valid JSON but not an object — refusing to overwrite`], changed: false, path });
102
+ }
103
+ } else {
104
+ root = {};
105
+ }
106
+
107
+ // ── compute the next entry at keyPath ────────────────────────────────────
108
+ const existing = getAt(root, keyPath);
109
+ const nextEntry =
110
+ mode === 'merge' && isPlainObject(existing) ? deepMerge(existing, entry) : entry;
111
+
112
+ // idempotent: if our slot already deep-equals what we'd write, do nothing.
113
+ if (existing !== undefined && deepEqual(existing, nextEntry)) {
114
+ return { action: 'skipped', changed: false, path };
115
+ }
116
+
117
+ // ── apply + atomic write ─────────────────────────────────────────────────
118
+ const next = cloneDeep(root);
119
+ setAt(next, keyPath, nextEntry);
120
+
121
+ const serialized = `${JSON.stringify(next, null, 2)}\n`;
122
+ atomicWrite(path, serialized);
123
+
124
+ return { action: fileExists ? 'updated' : 'created', changed: true, path };
125
+ }
126
+
127
+ // ── internal helpers ───────────────────────────────────────────────────────
128
+
129
+ function isPlainObject(v) {
130
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
131
+ }
132
+
133
+ function getAt(obj, keyPath) {
134
+ let cur = obj;
135
+ for (const k of keyPath) {
136
+ if (!isPlainObject(cur) || !(k in cur)) return undefined;
137
+ cur = cur[k];
138
+ }
139
+ return cur;
140
+ }
141
+
142
+ function setAt(obj, keyPath, value) {
143
+ let cur = obj;
144
+ for (let i = 0; i < keyPath.length - 1; i += 1) {
145
+ const k = keyPath[i];
146
+ if (!isPlainObject(cur[k])) cur[k] = {};
147
+ cur = cur[k];
148
+ }
149
+ cur[keyPath[keyPath.length - 1]] = value;
150
+ }
151
+
152
+ // Deep-merge `source` onto a copy of `target`. Objects merge recursively;
153
+ // arrays + scalars from source replace target's (no array concat — replacing
154
+ // args/command is the intended semantics).
155
+ function deepMerge(target, source) {
156
+ const out = cloneDeep(target);
157
+ for (const [k, v] of Object.entries(source)) {
158
+ if (isPlainObject(v) && isPlainObject(out[k])) {
159
+ out[k] = deepMerge(out[k], v);
160
+ } else {
161
+ out[k] = cloneDeep(v);
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ function cloneDeep(v) {
168
+ return v === undefined ? undefined : JSON.parse(JSON.stringify(v));
169
+ }
170
+
171
+ function deepEqual(a, b) {
172
+ return JSON.stringify(a) === JSON.stringify(b);
173
+ }
174
+
175
+ // Atomic write: serialize to a temp sibling, then rename over the target.
176
+ // rename is atomic on the same filesystem, so a reader never sees a partial file.
177
+ // Exported so install-agent.mjs (the per-agent wiring) shares ONE implementation
178
+ // — the kit's shared-module discipline (no two copies to drift).
179
+ //
180
+ // Two Windows-specific hazards this guards against (BOTH real, BOTH surfaced by
181
+ // the Task-50 stress runs — not "flakes": concrete EPERM-under-load failures):
182
+ // 1. The tmp suffix is UNIQUE PER CALL (randomUUID), not `process.pid` — a
183
+ // single install sequence writes the same path several times (write →
184
+ // uninstall → prune) and vitest runs files concurrently in one worker
185
+ // (shared pid), so a pid-keyed tmp name could collide.
186
+ // 2. `renameSync(tmp, target)` ONTO an existing file intermittently throws
187
+ // EPERM/EBUSY on Windows when the target is transiently locked (AV /
188
+ // indexer / another handle) under heavy parallel FS load. The kit already
189
+ // hit + documented this in persona-portability.mjs (restoreBackup); the
190
+ // proven fix is a short bounded retry. Without it, a `cmk install` on a
191
+ // busy Windows machine could spuriously fail mid-write.
192
+ export function atomicWrite(path, contents) {
193
+ mkdirSync(dirname(path), { recursive: true });
194
+ const tmp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
195
+ try {
196
+ writeFileSync(tmp, contents, 'utf8');
197
+ renameWithRetry(tmp, path);
198
+ } finally {
199
+ if (existsSync(tmp)) {
200
+ try {
201
+ unlinkSync(tmp);
202
+ } catch {
203
+ /* best-effort cleanup; the rename already succeeded or threw */
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ // renameSync with a bounded retry + BACKOFF on transient Windows EPERM/EBUSY
210
+ // (the target briefly locked by AV/indexer/another handle under load). A
211
+ // no-delay spin doesn't work — the lock lasts milliseconds while 5 immediate
212
+ // retries finish in microseconds (the Task-50 stress proved this: retry fired
213
+ // but exhausted instantly). So we sleep between attempts with a synchronous
214
+ // backoff (Atomics.wait — real sync sleep, no busy-spin). A non-transient error
215
+ // (e.g. ENOENT on the source) reraises immediately so we don't mask a real bug.
216
+ //
217
+ // `rename` + `sleep` are injectable so the retry policy is unit-testable WITHOUT
218
+ // mocking node:fs or actually sleeping — production passes nothing.
219
+ export function renameWithRetry(from, to, attempts = 8, rename = renameSync, sleep = sleepMs) {
220
+ let lastErr;
221
+ for (let i = 0; i < attempts; i += 1) {
222
+ try {
223
+ rename(from, to);
224
+ return;
225
+ } catch (err) {
226
+ if (err && (err.code === 'EPERM' || err.code === 'EBUSY' || err.code === 'EACCES')) {
227
+ lastErr = err; // transient lock under load — back off + retry
228
+ if (i < attempts - 1) sleep(Math.min(10 * 2 ** i, 250)); // 10,20,40…≤250ms
229
+ continue;
230
+ }
231
+ throw err; // anything else is a real error — don't mask it
232
+ }
233
+ }
234
+ throw lastErr;
235
+ }
236
+
237
+ // Synchronous sleep via Atomics.wait on a throwaway SharedArrayBuffer — blocks
238
+ // the thread for `ms` without a CPU busy-spin. Used only on the (rare) Windows
239
+ // transient-lock retry path, so the block is bounded + infrequent.
240
+ function sleepMs(ms) {
241
+ if (ms <= 0) return;
242
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
243
+ }
@@ -0,0 +1,43 @@
1
+ // read-json.mjs — BOM-tolerant JSON config reading.
2
+ //
3
+ // Windows editors (and PowerShell `Set-Content -Encoding utf8`) routinely write
4
+ // a UTF-8 BOM (U+FEFF / EF BB BF) at the start of a file. A bare
5
+ // `JSON.parse(readFileSync(path, 'utf8'))` throws on that leading BOM, so any kit
6
+ // reader of a USER-AUTHORED config file (Amazon Q `settings.json`, an agent's
7
+ // config) silently mis-reads a BOM'd file. The cut-gate-kiro live-test surfaced
8
+ // this: the Kiro default-agent guard read a BOM'd `settings.json`, the parse
9
+ // threw into its catch, and the guard concluded "no default agent set" — then
10
+ // CLOBBERED the user's existing default (D-187; the same silent-clobber class as
11
+ // D-184). These helpers make the kit's config reads BOM-tolerant; route every
12
+ // USER-AUTHORED config JSON read through them (kiro-cli-agent, mutate-agent-
13
+ // config, doctor HC-1, settings-hooks, config-core, semantic-backend).
14
+
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+
17
+ /** Strip a single leading UTF-8 BOM if present. Non-string input passes through. */
18
+ export function stripBom(text) {
19
+ if (typeof text !== 'string') return text;
20
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
21
+ }
22
+
23
+ /**
24
+ * Read + parse a JSON file, tolerating a leading BOM. Never throws: a missing
25
+ * file or malformed JSON returns `fallback` (default `undefined`) so callers can
26
+ * branch on the value instead of wrapping every read in try/catch.
27
+ *
28
+ * NOTE: missing-file and malformed-JSON both collapse to `fallback`. A caller
29
+ * that must DISTINGUISH the two (e.g. to surface a "parse error" message, or to
30
+ * refuse-to-clobber a corrupt file like mutate-agent-config / settings-hooks)
31
+ * should NOT use this — use `stripBom(readFileSync(...))` before its own parse.
32
+ *
33
+ * @param {string} path
34
+ * @param {{ fallback?: any }} [opts]
35
+ */
36
+ export function parseJsonFile(path, { fallback = undefined } = {}) {
37
+ if (!existsSync(path)) return fallback;
38
+ try {
39
+ return JSON.parse(stripBom(readFileSync(path, 'utf8')));
40
+ } catch {
41
+ return fallback;
42
+ }
43
+ }
package/src/reindex.mjs CHANGED
@@ -38,9 +38,22 @@ function extractHook(body) {
38
38
  return '';
39
39
  }
40
40
 
41
+ // Wrap any bare http(s):// URL in angle brackets so it doesn't trip markdownlint
42
+ // MD034 (no-bare-urls) when the INDEX ships in a user's committed repo. A URL
43
+ // already inside `<…>` or `](…)` is left alone (the char before it isn't `<`/`(`).
44
+ function autolinkBareUrls(text) {
45
+ return text.replace(/(^|[^<(])\b(https?:\/\/[^\s<>)\]]+)/g, '$1<$2>');
46
+ }
47
+
41
48
  function formatIndexLine({ id, type, title, filename, hook }) {
42
- const head = `- (${id}) [${type}] [${title}](${filename})`;
43
- return hook ? `${head} ${hook}` : head;
49
+ // Lint-clean the rendered INDEX line:
50
+ // - the title goes inside `[title]` link text: trim + collapse internal
51
+ // whitespace so a trailing space before `]` doesn't trip MD039
52
+ // (no-space-in-links).
53
+ // - the hook is trailing prose: wrap bare URLs (MD034).
54
+ const linkTitle = String(title ?? '').replace(/\s+/g, ' ').trim();
55
+ const head = `- (${id}) [${type}] [${linkTitle}](${filename})`;
56
+ return hook ? `${head} — ${autolinkBareUrls(hook)}` : head;
44
57
  }
45
58
 
46
59
  function listFactFiles(factDir) {