@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
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
- // Per design §5 the kit's hooks live in .claude/settings.json
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
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
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
- appendFileSync(targetPath, sectionHeader + '\n' + bulletLines + '\n', 'utf8');
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) {
@@ -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
- let dir = cwd;
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 → graceful shell:true fallback.
710
- compressLazyPath,
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;