@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
package/src/doctor.mjs
CHANGED
|
@@ -43,11 +43,14 @@ import { basename, join } from 'node:path';
|
|
|
43
43
|
import { nowIso } from './audit-log.mjs';
|
|
44
44
|
import { detectStaleLocks } from './lock-discipline.mjs';
|
|
45
45
|
import { cronSentinelPath } from './lazy-compress.mjs';
|
|
46
|
+
import { isCompactionNeeded } from './compaction-state.mjs';
|
|
46
47
|
import { getNativeAutoMemoryState } from './native-memory.mjs';
|
|
47
48
|
import { checkKitBinding, checkEmbedderBinding } from './native-binding.mjs';
|
|
48
49
|
import { resolveDefaultSearchMode } from './semantic-backend.mjs';
|
|
49
50
|
import { checkVersionDrift } from './version-drift.mjs';
|
|
50
51
|
import { getKitVersion } from './install.mjs';
|
|
52
|
+
import { hasOurCliAgent } from './kiro-cli-agent.mjs';
|
|
53
|
+
import { stripBom } from './read-json.mjs';
|
|
51
54
|
|
|
52
55
|
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
|
|
53
56
|
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
|
@@ -59,9 +62,41 @@ const MEMORY_DIR_REL = ['context', 'memory'];
|
|
|
59
62
|
const LOCKS_REL = ['context', '.locks'];
|
|
60
63
|
const NATIVE_MEMORY_LOG_REL = ['context', '.locks', 'native-memory-status.log'];
|
|
61
64
|
|
|
65
|
+
// Which agent was this project installed for? A `--ide kiro` install wires
|
|
66
|
+
// .kiro/ surfaces (hooks + steering/cmk.md + skills + settings/mcp.json); a
|
|
67
|
+
// Claude Code install wires .claude/settings.json. HC-1 must check the RIGHT
|
|
68
|
+
// surface — before v0.4.0 it hard-checked .claude/settings.json and false-FAILed
|
|
69
|
+
// on every Kiro install (the cut-gate-kiro live-test find, Task 50).
|
|
70
|
+
//
|
|
71
|
+
// Detection precedence:
|
|
72
|
+
// 1. .claude/settings.json present → Claude Code.
|
|
73
|
+
// 2. a CMK-OWNED Kiro marker present (.kiro/steering/cmk.md — written by every
|
|
74
|
+
// installKiro run) → Kiro. We key on OUR marker, not a bare `.kiro/` dir, so
|
|
75
|
+
// a stray/unrelated .kiro/ (another tool's, or a partial non-cmk dir) does
|
|
76
|
+
// NOT flip the project to the Kiro path (I2).
|
|
77
|
+
// 3. neither → Claude Code (historical default — a not-yet-installed project
|
|
78
|
+
// still reports the Claude-shaped repair hint).
|
|
79
|
+
//
|
|
80
|
+
// NOTE (I1, deliberate punt): a project installed for BOTH agents (.claude AND a
|
|
81
|
+
// cmk .kiro marker) resolves to Claude Code by precedence — HC-1 checks only the
|
|
82
|
+
// Claude surface. Dual-install is rare; the single-surface check is intentional
|
|
83
|
+
// for v0.4.0, not an oversight. Revisit if dual-install becomes common.
|
|
84
|
+
function detectInstallKind(projectRoot) {
|
|
85
|
+
if (existsSync(join(projectRoot, '.claude', 'settings.json'))) return 'claude-code';
|
|
86
|
+
if (existsSync(join(projectRoot, '.kiro', 'steering', 'cmk.md'))) return 'kiro';
|
|
87
|
+
return 'claude-code';
|
|
88
|
+
}
|
|
89
|
+
|
|
62
90
|
// --- HC-1: Stop + SessionStart hooks registered -----------------------
|
|
63
|
-
function hc1Hooks({ projectRoot }) {
|
|
64
|
-
//
|
|
91
|
+
function hc1Hooks({ projectRoot, awsDir }) {
|
|
92
|
+
// Agent-aware (v0.4.0): a Kiro install keeps its hooks in .kiro/hooks/ (IDE)
|
|
93
|
+
// and/or ~/.kiro/agents/ (kiro-cli), so route to the Kiro check
|
|
94
|
+
// rather than false-failing on a missing .claude/settings.json with a
|
|
95
|
+
// Claude-Code repair hint.
|
|
96
|
+
if (detectInstallKind(projectRoot) === 'kiro') {
|
|
97
|
+
return hc1KiroHooks({ projectRoot, awsDir });
|
|
98
|
+
}
|
|
99
|
+
// Per design §5 — the Claude Code hooks live in .claude/settings.json
|
|
65
100
|
// alongside its plugin manifest. Required for auto-extract +
|
|
66
101
|
// session-end compression to fire.
|
|
67
102
|
const settingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
@@ -76,7 +111,9 @@ function hc1Hooks({ projectRoot }) {
|
|
|
76
111
|
}
|
|
77
112
|
let settings;
|
|
78
113
|
try {
|
|
79
|
-
|
|
114
|
+
// stripBom: a Windows-editor BOM on .claude/settings.json must not make a
|
|
115
|
+
// valid file read as a parse error → false HC-1 FAIL (D-187).
|
|
116
|
+
settings = JSON.parse(stripBom(readFileSync(settingsPath, 'utf8')));
|
|
80
117
|
} catch (err) {
|
|
81
118
|
return {
|
|
82
119
|
id: 'HC-1',
|
|
@@ -141,6 +178,51 @@ function hc1Hooks({ projectRoot }) {
|
|
|
141
178
|
};
|
|
142
179
|
}
|
|
143
180
|
|
|
181
|
+
// --- HC-1 (Kiro variant): capture + inject can fire via EITHER Kiro surface ----
|
|
182
|
+
// Kiro wires capture/inject through TWO independent surfaces (D-186):
|
|
183
|
+
// • IDE hooks → .kiro/hooks/{cmk-capture,cmk-inject}.kiro.hook (the GUI user)
|
|
184
|
+
// • CLI agent → ~/.kiro/agents/cmk.json with
|
|
185
|
+
// agentSpawn(inject)+stop(capture) hooks (the kiro-cli user)
|
|
186
|
+
// HC-1 is a CAPABILITY check ("can capture/inject fire?"), not a single-file
|
|
187
|
+
// check — so it PASSES if EITHER surface is present, and FAILs only when NEITHER
|
|
188
|
+
// is. The original v0.4.0 fix checked only the IDE hooks, which false-FAILed a
|
|
189
|
+
// working kiro-cli-only install (the surface lives in ~/.aws, which is also
|
|
190
|
+
// machine-local and doesn't travel with a clone) — the same separately-correct-
|
|
191
|
+
// jointly-broken class as D-184/D-185, one level down (skill-review B1).
|
|
192
|
+
// `awsDir` is injectable so tests can sandbox the ~/.aws probe.
|
|
193
|
+
function hc1KiroHooks({ projectRoot, awsDir }) {
|
|
194
|
+
const hooksDir = join(projectRoot, '.kiro', 'hooks');
|
|
195
|
+
const ideHooks = ['cmk-capture.kiro.hook', 'cmk-inject.kiro.hook'];
|
|
196
|
+
const ideMissing = ideHooks.filter((f) => !existsSync(join(hooksDir, f)));
|
|
197
|
+
const ideComplete = ideMissing.length === 0;
|
|
198
|
+
const cliAgent = hasOurCliAgent({ awsDir });
|
|
199
|
+
|
|
200
|
+
if (ideComplete || cliAgent) {
|
|
201
|
+
const surfaces = [];
|
|
202
|
+
if (ideComplete) surfaces.push('IDE hooks (.kiro/hooks/)');
|
|
203
|
+
if (cliAgent) surfaces.push('CLI agent (~/.kiro/agents/cmk.json)');
|
|
204
|
+
return {
|
|
205
|
+
id: 'HC-1',
|
|
206
|
+
name: 'Stop + SessionStart hooks registered',
|
|
207
|
+
status: 'pass',
|
|
208
|
+
message: `Kiro capture/inject wired via ${surfaces.join(' + ')}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Neither surface present → genuinely not wired. Name both so a kiro-cli user
|
|
213
|
+
// isn't pushed at the IDE-only repair.
|
|
214
|
+
return {
|
|
215
|
+
id: 'HC-1',
|
|
216
|
+
name: 'Stop + SessionStart hooks registered',
|
|
217
|
+
status: 'fail',
|
|
218
|
+
message:
|
|
219
|
+
`Kiro install: no capture/inject surface found — neither the IDE hooks ` +
|
|
220
|
+
`(${ideMissing.join(', ')} in .kiro/hooks/) nor a cmk CLI agent in ` +
|
|
221
|
+
`~/.kiro/agents/`,
|
|
222
|
+
recoveryCommand: 'cmk install --ide kiro',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
144
226
|
// --- HC-2: distill freshness (≤2 days) --------------------------------
|
|
145
227
|
function hc2DistillFreshness({ projectRoot, now }) {
|
|
146
228
|
const recentPath = join(projectRoot, ...RECENT_MD_REL);
|
|
@@ -559,6 +641,45 @@ function hc9VersionDrift({ projectRoot, kitVersion }) {
|
|
|
559
641
|
return checkVersionDrift({ claudeMdText, kitVersion });
|
|
560
642
|
}
|
|
561
643
|
|
|
644
|
+
// --- HC-10: Scheduled compaction liveness (Task 167 / D-207) ----------
|
|
645
|
+
// INFORMATIONAL — memory self-heals automatically every session (the lazy roll
|
|
646
|
+
// is the floor, 167.A/D), so a dead cron is a degraded OPTIMIZATION, not data
|
|
647
|
+
// loss. NEVER prescribes a manual heal (no recoveryCommand). A DEV/diagnostic
|
|
648
|
+
// aid (it surfaced THIS bug class) + a heads-up for a power user. SKIPs when no
|
|
649
|
+
// cron is registered (the default) — the lazy roll covers compaction there.
|
|
650
|
+
function hc10CompactionLiveness({ projectRoot, now }) {
|
|
651
|
+
const v = isCompactionNeeded({ projectRoot, now });
|
|
652
|
+
// No cron registered (heartbeatAge null) → nothing to check; the lazy roll owns
|
|
653
|
+
// compaction. SKIP (consistent with HC-5's optional-cron posture).
|
|
654
|
+
if (v.heartbeatAge === null) {
|
|
655
|
+
return {
|
|
656
|
+
id: 'HC-10',
|
|
657
|
+
name: 'Scheduled compaction is alive',
|
|
658
|
+
status: 'skip',
|
|
659
|
+
message: 'no scheduled cron registered (optional) — memory self-heals each session via the lazy roll.',
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
if (v.cronStale) {
|
|
663
|
+
const days = Math.floor(v.heartbeatAge / (24 * 60 * 60 * 1000));
|
|
664
|
+
return {
|
|
665
|
+
id: 'HC-10',
|
|
666
|
+
name: 'Scheduled compaction is alive',
|
|
667
|
+
status: 'fail',
|
|
668
|
+
// Informational framing — NO recoveryCommand. The kit self-heals; this is a
|
|
669
|
+
// heads-up that the OPTIONAL nightly schedule isn't firing (asleep at 23:00,
|
|
670
|
+
// a registration that didn't take catch-up). Re-running register-crons is a
|
|
671
|
+
// user choice, not a required repair.
|
|
672
|
+
message: `your scheduled compaction looks dead (last ran ~${days}d ago) — memory still self-heals automatically each session, so no action is needed; run \`cmk register-crons\` only if you want the nightly schedule back.`,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
id: 'HC-10',
|
|
677
|
+
name: 'Scheduled compaction is alive',
|
|
678
|
+
status: 'pass',
|
|
679
|
+
message: 'scheduled compaction heartbeat is fresh',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
562
683
|
export async function runDoctor({
|
|
563
684
|
projectRoot,
|
|
564
685
|
userDir,
|
|
@@ -566,6 +687,7 @@ export async function runDoctor({
|
|
|
566
687
|
kitVersion,
|
|
567
688
|
kitBindingProbe,
|
|
568
689
|
embedderBindingProbe,
|
|
690
|
+
awsDir, // injectable: sandboxes the HC-1 Kiro CLI-agent (~/.aws) probe in tests
|
|
569
691
|
} = {}) {
|
|
570
692
|
const t0 = Date.now();
|
|
571
693
|
if (!projectRoot) {
|
|
@@ -580,7 +702,7 @@ export async function runDoctor({
|
|
|
580
702
|
const resolvedUserDir = userDir ?? join(homedir(), '.claude-memory-kit');
|
|
581
703
|
|
|
582
704
|
// Run all checks in order.
|
|
583
|
-
const c1 = hc1Hooks({ projectRoot });
|
|
705
|
+
const c1 = hc1Hooks({ projectRoot, awsDir });
|
|
584
706
|
const c2 = hc2DistillFreshness({ projectRoot, now: ts });
|
|
585
707
|
const c3 = hc3Transcripts({ projectRoot, now: ts });
|
|
586
708
|
const c4 = hc4IndexConsistency({ projectRoot });
|
|
@@ -590,10 +712,11 @@ export async function runDoctor({
|
|
|
590
712
|
const c8 = await hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBindingProbe });
|
|
591
713
|
// HC-9: kitVersion injectable for tests; defaults to the installed binary's version.
|
|
592
714
|
const c9 = hc9VersionDrift({ projectRoot, kitVersion: kitVersion ?? getKitVersion() });
|
|
715
|
+
const c10 = hc10CompactionLiveness({ projectRoot, now: ts });
|
|
593
716
|
|
|
594
717
|
return {
|
|
595
718
|
action: 'completed',
|
|
596
|
-
checks: [c1, c2, c3, c4, c5, c6, c7, c8, c9],
|
|
719
|
+
checks: [c1, c2, c3, c4, c5, c6, c7, c8, c9, c10],
|
|
597
720
|
duration_ms: Date.now() - t0,
|
|
598
721
|
};
|
|
599
722
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// guard-memory.mjs — the memory delete-guardrail core (D-192).
|
|
2
|
+
//
|
|
3
|
+
// A Claude Code `PreToolUse` hook (wired by `cmk install`) calls the
|
|
4
|
+
// `cmk-guard-memory` bin before every Bash/PowerShell tool call. This module
|
|
5
|
+
// is the pure decision: given a shell command, should it be BLOCKED because it
|
|
6
|
+
// would delete a claude-memory-kit memory path?
|
|
7
|
+
//
|
|
8
|
+
// Why this exists: 2026-06-22 (D-192), a `cd` that silently failed left a
|
|
9
|
+
// following `rm -f context/sessions/* context/transcripts/*` running in the
|
|
10
|
+
// wrong repo, deleting gitignored (non-recoverable) memory. A prose rule relies
|
|
11
|
+
// on the agent remembering; this hook enforces it structurally — the command
|
|
12
|
+
// never runs. Recovered only via an off-machine backup; this prevents the next.
|
|
13
|
+
//
|
|
14
|
+
// Design: BROAD by intent. A false block is recoverable (the user rephrases or
|
|
15
|
+
// deletes by hand); a false allow is the data loss we're preventing. So the
|
|
16
|
+
// predicates favor over-blocking a memory delete over under-blocking one.
|
|
17
|
+
|
|
18
|
+
// A command is destructive if it invokes a delete/destroy verb OR an equivalent
|
|
19
|
+
// data-destroying mechanism that carries no `rm` token (find -delete, truncate,
|
|
20
|
+
// a `>`/`:>` redirection that truncates a file). Skill-review I2: a memory file
|
|
21
|
+
// can be destroyed with no `rm` at all.
|
|
22
|
+
const DESTRUCTIVE = [
|
|
23
|
+
/\brm\b/i, // unix rm
|
|
24
|
+
/\bRemove-Item\b/i, // PowerShell
|
|
25
|
+
/\brmdir\b/i,
|
|
26
|
+
/\brd\b/i, // cmd rd
|
|
27
|
+
/\bdel\b/i, // cmd del
|
|
28
|
+
/\bunlink\b/i,
|
|
29
|
+
/\bgit\s+clean\b/i,
|
|
30
|
+
/\bgit\s+reset\s+--hard\b/i,
|
|
31
|
+
/\bfind\b[^|]*-delete\b/i, // find … -delete
|
|
32
|
+
/\btruncate\b/i, // truncate -s0 file
|
|
33
|
+
/(^|[\s;&|])>\s*\S/, // a `>`/`> file` redirection (truncates the target to empty)
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// A path is a MEMORY path if the command mentions any of these. The bare
|
|
37
|
+
// `context` segment matcher requires a path-ish boundary so the WORD "context"
|
|
38
|
+
// (e.g. `grep context`, `rm contextual.md`) is not a false positive.
|
|
39
|
+
const MEMORY_TOKENS = [
|
|
40
|
+
/context\/sessions/i,
|
|
41
|
+
/context\/transcripts/i,
|
|
42
|
+
/context\/memory/i,
|
|
43
|
+
/context\.local/i,
|
|
44
|
+
// a `context` path segment: `context/`, `context\`, `./context`, `repo/context`,
|
|
45
|
+
// or a bare ` context` argument (e.g. `git clean -fd context`).
|
|
46
|
+
/(^|[\s'"./\\])context([\s'"/\\]|$)/i,
|
|
47
|
+
/\.claude-memory-kit/i, // the cross-project user tier
|
|
48
|
+
/MEMORY\.md/i,
|
|
49
|
+
/DECISIONS\.md/i,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** True if the command invokes a delete/destroy verb. */
|
|
53
|
+
export function isDestructive(cmd) {
|
|
54
|
+
return typeof cmd === 'string' && DESTRUCTIVE.some((re) => re.test(cmd));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True if the command references a claude-memory-kit memory path. */
|
|
58
|
+
export function touchesMemory(cmd) {
|
|
59
|
+
return typeof cmd === 'string' && MEMORY_TOKENS.some((re) => re.test(cmd));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A SEGMENT (not the whole command) may be exempt: it only MENTIONS a delete in
|
|
63
|
+
// its text and can't itself execute one — a `git commit` whose message describes
|
|
64
|
+
// a delete, an `echo`/`grep`/`cat` of text about a delete. (oh-my-kiro's
|
|
65
|
+
// block-dangerous.sh exempts `git commit` for the same reason.) CRITICAL: this
|
|
66
|
+
// is applied PER SEGMENT, never to a whole compound command — `echo x && rm -rf
|
|
67
|
+
// context/memory` must NOT be exempted by its leading `echo` (skill-review B1).
|
|
68
|
+
const SEGMENT_EXEMPT = [
|
|
69
|
+
/^\s*git\s+commit\b/i,
|
|
70
|
+
/^\s*git\s+log\b/i,
|
|
71
|
+
/^\s*echo\b/i,
|
|
72
|
+
/^\s*(grep|rg|cat|less|head|tail|sed -n|awk)\b/i,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// Split a shell command into its sequenced segments on the separators that start
|
|
76
|
+
// a NEW command: && || ; | and newlines. (A `>` redirection is NOT a separator —
|
|
77
|
+
// it's handled as a destructive mechanism on its own segment.) Also surface any
|
|
78
|
+
// $(...) / `...` command substitution as its own segment so a delete hidden in a
|
|
79
|
+
// commit-message arg (`git commit -m "$(rm -rf context/memory)"`) is evaluated
|
|
80
|
+
// on its own and can't ride the outer command's exemption (skill-review I1).
|
|
81
|
+
function splitSegments(cmd) {
|
|
82
|
+
const segments = [];
|
|
83
|
+
// pull out command substitutions first, add them as standalone segments
|
|
84
|
+
const subRe = /\$\(([^)]*)\)|`([^`]*)`/g;
|
|
85
|
+
let m;
|
|
86
|
+
while ((m = subRe.exec(cmd)) !== null) {
|
|
87
|
+
const inner = m[1] ?? m[2];
|
|
88
|
+
if (inner && inner.trim()) segments.push(inner);
|
|
89
|
+
}
|
|
90
|
+
const stripped = cmd.replace(subRe, ' '); // remove subs so they don't leak into outer segments
|
|
91
|
+
for (const seg of stripped.split(/&&|\|\||;|\||\r?\n/)) {
|
|
92
|
+
if (seg.trim()) segments.push(seg);
|
|
93
|
+
}
|
|
94
|
+
return segments;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isSegmentExempt(seg) {
|
|
98
|
+
return SEGMENT_EXEMPT.some((re) => re.test(seg));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The pure decision. Returns { block: boolean, reason?: string }.
|
|
103
|
+
* Splits the command into segments and BLOCKS if ANY non-exempt segment is both
|
|
104
|
+
* destructive AND aimed at a memory path. Per-segment so an exempt verb in front
|
|
105
|
+
* (`echo …`, `git commit …`) cannot launder a chained real delete (B1/I1).
|
|
106
|
+
*/
|
|
107
|
+
export function decideGuard(cmd) {
|
|
108
|
+
if (typeof cmd !== 'string' || cmd.trim() === '') return { block: false };
|
|
109
|
+
const offending = splitSegments(cmd).some(
|
|
110
|
+
(seg) => !isSegmentExempt(seg) && isDestructive(seg) && touchesMemory(seg),
|
|
111
|
+
);
|
|
112
|
+
if (offending) {
|
|
113
|
+
return {
|
|
114
|
+
block: true,
|
|
115
|
+
reason:
|
|
116
|
+
'BLOCKED by the claude-memory-kit delete-guardrail: this command deletes a ' +
|
|
117
|
+
'memory path (context/ , the persona tier ~/.claude-memory-kit, or a memory ' +
|
|
118
|
+
'file). Memory is precious and a delete here is often non-recoverable. If you ' +
|
|
119
|
+
'REALLY mean to delete memory, do it by hand after a backup — or ask the user. ' +
|
|
120
|
+
'NEVER run a delete after a `cd` you have not verified with `pwd`.',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { block: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The shell-tool names across agents. Claude Code: `Bash` / `PowerShell`.
|
|
127
|
+
// Kiro / Amazon-Q: `execute_bash` (+ legacy alias `executeBash`); kiro-cli V3
|
|
128
|
+
// (2.9.0) RENAMED it to `execute_command` (D-198, observed live in the cut-gate).
|
|
129
|
+
// Payload `.tool_name` + `.tool_input.command` on STDIN — the SAME shape as
|
|
130
|
+
// Claude Code, so one bin guards every agent. D-192.
|
|
131
|
+
const SHELL_TOOLS = new Set([
|
|
132
|
+
'Bash',
|
|
133
|
+
'PowerShell',
|
|
134
|
+
'execute_bash',
|
|
135
|
+
'executeBash',
|
|
136
|
+
'execute_command', // kiro-cli V3 (2.9.0) rename — D-198
|
|
137
|
+
'shell',
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Evaluate a PreToolUse payload (already parsed) from EITHER Claude Code or Kiro
|
|
142
|
+
* — both deliver `{ tool_name, tool_input: { command } }` on stdin. Only shell
|
|
143
|
+
* tools are inspected; everything else is allowed. Returns { block, reason? }.
|
|
144
|
+
*/
|
|
145
|
+
export function evaluatePayload(payload) {
|
|
146
|
+
const tool = payload?.tool_name;
|
|
147
|
+
if (!SHELL_TOOLS.has(tool)) return { block: false };
|
|
148
|
+
const cmd = payload?.tool_input?.command;
|
|
149
|
+
if (typeof cmd !== 'string' || cmd === '') return { block: false };
|
|
150
|
+
return decideGuard(cmd);
|
|
151
|
+
}
|
|
@@ -253,7 +253,21 @@ export async function importAnthropicMemory({
|
|
|
253
253
|
})
|
|
254
254
|
.join('\n');
|
|
255
255
|
mkdirSync(join(projectRoot, 'context'), { recursive: true });
|
|
256
|
-
|
|
256
|
+
// Lint-clean append (MD022): guarantee exactly one blank line ABOVE the
|
|
257
|
+
// `## Imported …` heading when the target file already has content (the
|
|
258
|
+
// leading `\n` in sectionHeader assumes the file ends in `\n` — fragile if it
|
|
259
|
+
// doesn't). The blank below the heading is the `+ '\n'` after sectionHeader.
|
|
260
|
+
let prefix = '';
|
|
261
|
+
if (existsSync(targetPath)) {
|
|
262
|
+
const existing = readFileSync(targetPath, 'utf8');
|
|
263
|
+
if (existing.trim() !== '') {
|
|
264
|
+
prefix = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '' : '\n';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// sectionHeader already starts with `\n`, so a file ending in `\n` yields the
|
|
268
|
+
// needed blank line; prefix adds the missing one only when the file lacks a
|
|
269
|
+
// trailing newline.
|
|
270
|
+
appendFileSync(targetPath, prefix + sectionHeader + '\n' + bulletLines + '\n', 'utf8');
|
|
257
271
|
|
|
258
272
|
let accepted = 0;
|
|
259
273
|
for (const p of proposals) {
|
package/src/inject-context.mjs
CHANGED
|
@@ -31,9 +31,10 @@ import {
|
|
|
31
31
|
closeSync,
|
|
32
32
|
} from 'node:fs';
|
|
33
33
|
import { spawn } from 'node:child_process';
|
|
34
|
-
import { join } from 'node:path';
|
|
34
|
+
import { join, dirname } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
35
36
|
import { homedir } from 'node:os';
|
|
36
|
-
import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
|
|
37
|
+
import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN, discoverRootUpward } from './tier-paths.mjs';
|
|
37
38
|
import { nowIso } from './audit-log.mjs';
|
|
38
39
|
import { detectStaleness, isJournalStale } from './lazy-compress.mjs';
|
|
39
40
|
import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
|
|
@@ -170,21 +171,14 @@ function latestDaySession(sessionsDir) {
|
|
|
170
171
|
// kit's project-tier root convention is `<repo>/context/`; the walk-up
|
|
171
172
|
// matches `git rev-parse --show-toplevel`'s semantics for nested invocations
|
|
172
173
|
// (a hook may fire while Claude Code's cwd is in a sub-package).
|
|
174
|
+
// Task 168: discovery walks up to the nearest project root, recognizing EITHER
|
|
175
|
+
// tier directory — `context/` (committed) OR `context.local/` (gitignored local)
|
|
176
|
+
// — so a local-only project resolves correctly, and STOPPING at the home dir so a
|
|
177
|
+
// stray `~/context/` can't hijack discovery from an unrelated subdir. Shares the
|
|
178
|
+
// single `discoverRootUpward` implementation with resolveMcpProjectRoot (one
|
|
179
|
+
// home-boundary + canonicalize algorithm, no drift across the two walkers).
|
|
173
180
|
function discoverProjectRoot(cwd) {
|
|
174
|
-
|
|
175
|
-
// Defensive bound: walk no more than 64 ancestors.
|
|
176
|
-
for (let i = 0; i < 64; i++) {
|
|
177
|
-
if (existsSync(join(dir, 'context'))) return dir;
|
|
178
|
-
const parent = join(dir, '..');
|
|
179
|
-
const norm = statSync(parent).isDirectory() ? parent : null;
|
|
180
|
-
if (!norm || norm === dir) break;
|
|
181
|
-
// Stop at the filesystem root.
|
|
182
|
-
if (/^[A-Za-z]:\\?$|^\/$/.test(dir)) break;
|
|
183
|
-
dir = parent;
|
|
184
|
-
}
|
|
185
|
-
// Fall back to `cwd` — the per-tier readers will return empty for
|
|
186
|
-
// absent dirs, so this stays safe.
|
|
187
|
-
return cwd;
|
|
181
|
+
return discoverRootUpward(cwd, ['context', 'context.local']);
|
|
188
182
|
}
|
|
189
183
|
|
|
190
184
|
function tierDirExists(tier, tierRoot) {
|
|
@@ -617,6 +611,32 @@ function enforceCap(orderedBlocks, capBytes, ts, reportCapBytes = capBytes) {
|
|
|
617
611
|
* Exposed so injectContext can override via dependency injection in tests
|
|
618
612
|
* (testSpawnLazy parameter) — production callers pass nothing.
|
|
619
613
|
*/
|
|
614
|
+
/**
|
|
615
|
+
* Resolve the path to `bin/cmk-compress-lazy.mjs` from THIS module's location
|
|
616
|
+
* (`src/` → `../bin/`), honoring the $CMK_COMPRESS_LAZY_PATH override. Returns
|
|
617
|
+
* null if it can't be found (→ the shell:true fallback).
|
|
618
|
+
*
|
|
619
|
+
* Why this exists (the cross-agent console-popup fix): the no-popup spawn (Task
|
|
620
|
+
* 81) only kicks in when `injectContext` receives a real `compressLazyPath` —
|
|
621
|
+
* otherwise `lazyCompressSpawnDescriptor` falls to the `shell:true` `.cmd` shim,
|
|
622
|
+
* which flashes a `node` console window on Windows. The Claude Code bin
|
|
623
|
+
* (`cmk-inject-context.mjs`) passed the path; the Kiro `cmk hook agentSpawn`
|
|
624
|
+
* path did NOT, so a real Kiro user got the popup (the cut-gate-kiro live find).
|
|
625
|
+
* Resolving it HERE (in injectContext's default) fixes EVERY caller — Claude
|
|
626
|
+
* bin, Kiro hook, any future agent — not just the ones that remember to pass it.
|
|
627
|
+
*/
|
|
628
|
+
export function resolveCompressLazyPath() {
|
|
629
|
+
const fromEnv = process.env.CMK_COMPRESS_LAZY_PATH;
|
|
630
|
+
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
|
631
|
+
try {
|
|
632
|
+
const here = dirname(fileURLToPath(import.meta.url)); // .../packages/cli/src
|
|
633
|
+
const candidate = join(here, '..', 'bin', 'cmk-compress-lazy.mjs');
|
|
634
|
+
return existsSync(candidate) ? candidate : null;
|
|
635
|
+
} catch {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
620
640
|
/**
|
|
621
641
|
* Pure spawn descriptor for the lazy-compress child (Task 81). Separated so the
|
|
622
642
|
* Door-3 contract (node-direct + windowsHide, no shell, when the path is known)
|
|
@@ -706,8 +726,12 @@ export function injectContext({
|
|
|
706
726
|
// Resolved path to cmk-compress-lazy.mjs (passed by the bin wrapper, which
|
|
707
727
|
// knows the install layout). Lets spawnLazyCompress run `node <path>`
|
|
708
728
|
// directly instead of the shell:true `.cmd` shim — the Windows
|
|
709
|
-
// console-popup fix (Task 81). Absent →
|
|
710
|
-
|
|
729
|
+
// console-popup fix (Task 81). Absent → self-resolve from this module's
|
|
730
|
+
// location (resolveCompressLazyPath), so EVERY caller gets the no-popup
|
|
731
|
+
// node-direct spawn — not just the Claude bin that passed it explicitly. A
|
|
732
|
+
// caller may still override (e.g. tests). Only a genuinely-unfindable bin
|
|
733
|
+
// (corrupt install) falls to the shell:true descriptor.
|
|
734
|
+
compressLazyPath = resolveCompressLazyPath(),
|
|
711
735
|
} = {}) {
|
|
712
736
|
const ts = now ?? nowIso();
|
|
713
737
|
const cap = typeof capBytes === 'number' ? capBytes : DEFAULT_CAP_BYTES;
|