@cachly-dev/mcp-server 0.9.12 → 0.9.13

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/dist/index.js CHANGED
@@ -4925,6 +4925,8 @@ async function handleTool(name, args) {
4925
4925
  }
4926
4926
  catch { /* non-critical */ }
4927
4927
  // ── Layer 1+2: CKG update (Causal Knowledge Graph + Belief Update Engine) ──
4928
+ // BUE: Bayesian confidence, contradiction detection, second-degree propagation, decay
4929
+ let beliefConflict = null;
4928
4930
  try {
4929
4931
  const conceptId = ckgSlug(topic);
4930
4932
  const domain = topic.split(':')[0] ?? 'unknown';
@@ -4962,6 +4964,71 @@ async function handleTool(name, args) {
4962
4964
  await ckgUpdateEdge(redis, conceptId, 'causes', causeId, false);
4963
4965
  }
4964
4966
  }
4967
+ // ── BUE: Contradiction detection ──────────────────────────────────────
4968
+ // If this topic previously had a confirmed 'fixes' edge (confidence > 0.7)
4969
+ // and now outcome=failure → flag belief_conflict
4970
+ if (outcome === 'failure') {
4971
+ const existingEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
4972
+ for (const ek of existingEdgeKeys) {
4973
+ const er = await redis.get(ek);
4974
+ if (!er)
4975
+ continue;
4976
+ const existEdge = JSON.parse(er);
4977
+ if (existEdge.edgeType === 'fixes' && existEdge.confidence > 0.7 && existEdge.trials >= 3) {
4978
+ beliefConflict = `⚠️ **belief_conflict** on \`${topic}\`: previously confirmed fix (confidence ${existEdge.confidence.toFixed(2)}, n=${existEdge.trials}) now reports failure. Both beliefs retained as \`contested\`. Use \`ckg_inspect(concept="${conceptId}")\` to review.`;
4979
+ // Store conflict marker
4980
+ const conflictKey = `cachly:ckg:conflict:${conceptId}`;
4981
+ await redis.set(conflictKey, JSON.stringify({ topic, detected_at: new Date().toISOString(), fix_confidence: existEdge.confidence, fix_trials: existEdge.trials, failure_outcome: outcome }), 'EX', 60 * 60 * 24 * 90);
4982
+ }
4983
+ }
4984
+ }
4985
+ // ── BUE: Second-degree propagation ────────────────────────────────────
4986
+ // When a 'fixes' edge gets stronger, boost co-occurring second-degree edges slightly
4987
+ if (outcome === 'success' && (domain === 'fix' || domain === 'debug')) {
4988
+ const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
4989
+ for (const ek of fromEdgeKeys.slice(0, 10)) {
4990
+ const er = await redis.get(ek);
4991
+ if (!er)
4992
+ continue;
4993
+ const e2 = JSON.parse(er);
4994
+ if (e2.edgeType !== 'fixes')
4995
+ continue;
4996
+ // Boost second-degree: edges from e2.to get a small fractional success
4997
+ const secondKeys = await redis.smembers(`cachly:ckg:idx:from:${e2.to}`);
4998
+ for (const sk of secondKeys.slice(0, 5)) {
4999
+ const sr = await redis.get(sk);
5000
+ if (!sr)
5001
+ continue;
5002
+ const se = JSON.parse(sr);
5003
+ if (se.edgeType !== 'co-occurs')
5004
+ continue;
5005
+ // Add 0.1 fractional success (second-degree signal)
5006
+ se.successes = (se.successes || 0) + 0.1;
5007
+ se.trials = (se.trials || 0) + 0.1;
5008
+ se.confidence = (se.successes + 1) / (se.trials + 2);
5009
+ se.last_updated = new Date().toISOString();
5010
+ await redis.set(sk, JSON.stringify(se));
5011
+ }
5012
+ }
5013
+ }
5014
+ // ── BUE: Stale edge decay ─────────────────────────────────────────────
5015
+ // Edges older than 90 days with < 3 trials decay by 10% confidence.
5016
+ // Only run probabilistically (1% of calls) to avoid per-call overhead.
5017
+ if (Math.random() < 0.01) {
5018
+ const decayCutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
5019
+ const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
5020
+ for (const ek of fromEdgeKeys) {
5021
+ const er = await redis.get(ek);
5022
+ if (!er)
5023
+ continue;
5024
+ const de = JSON.parse(er);
5025
+ if (de.trials < 3 && de.last_updated && new Date(de.last_updated).getTime() < decayCutoff) {
5026
+ de.confidence = de.confidence * 0.9;
5027
+ de.last_updated = new Date().toISOString();
5028
+ await redis.set(ek, JSON.stringify(de));
5029
+ }
5030
+ }
5031
+ }
4965
5032
  }
4966
5033
  catch { /* CKG updates are non-critical */ }
4967
5034
  const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
@@ -4969,6 +5036,7 @@ async function handleTool(name, args) {
4969
5036
  const action = isUpdate ? 'updated' : 'stored';
4970
5037
  return [
4971
5038
  `${emoji} **Lesson ${action}:** \`${topic}\` (${outcome}) ${sevEmoji} ${severity}`,
5039
+ beliefConflict ?? '',
4972
5040
  ``,
4973
5041
  `**What worked:** ${what_worked}`,
4974
5042
  what_failed ? `**What failed:** ${what_failed}` : '',
@@ -5470,6 +5538,24 @@ async function handleTool(name, args) {
5470
5538
  lines.push('');
5471
5539
  }
5472
5540
  }
5541
+ // ── Layer 7 MCM: Active belief conflicts ─────────────────────────────────
5542
+ try {
5543
+ const conflictKeys = [];
5544
+ const cfStream = redis.scanStream({ match: 'cachly:ckg:conflict:*', count: 50 });
5545
+ await new Promise((res, rej) => { cfStream.on('data', (b) => conflictKeys.push(...b)); cfStream.on('end', res); cfStream.on('error', rej); });
5546
+ if (conflictKeys.length > 0) {
5547
+ lines.push(`⚡ **Active belief conflicts (${conflictKeys.length}):**`);
5548
+ for (const ck of conflictKeys.slice(0, 3)) {
5549
+ const cr = await redis.get(ck);
5550
+ if (!cr)
5551
+ continue;
5552
+ const cf = JSON.parse(cr);
5553
+ lines.push(` ⚠️ \`${cf.topic}\` — previously confirmed fix (${(cf.fix_confidence * 100).toFixed(0)}%, n=${cf.fix_trials}) now contradicted. Use \`ckg_inspect(concept="${ckgSlug(cf.topic)}")\``);
5554
+ }
5555
+ lines.push('');
5556
+ }
5557
+ }
5558
+ catch { /* non-critical */ }
5473
5559
  if (focusLessons.length > 0) {
5474
5560
  lines.push(`🎯 **Relevant for "${focus}":**`);
5475
5561
  for (const l of focusLessons.slice(0, 4)) {
@@ -8758,107 +8844,114 @@ smart_recall(
8758
8844
  const { resolve } = await import('node:path');
8759
8845
  const repoDir = resolve(repo_path);
8760
8846
  const maxCommits = Math.min(Number(limit) || 100, 500);
8761
- // Verify it's a git repo
8847
+ // Semaphore: max 10 concurrent git subprocesses per MCP process
8848
+ await _gitSemAcquire();
8762
8849
  try {
8763
- execSync('git rev-parse --git-dir', { cwd: repoDir, stdio: 'pipe' });
8764
- }
8765
- catch {
8766
- return `❌ Not a git repository: \`${repoDir}\`. Pass \`repo_path\` pointing to a git checkout.`;
8767
- }
8768
- // Build git log command
8769
- const sinceFlag = since ? `--since="${since}"` : '';
8770
- const logCmd = `git log ${branch} ${sinceFlag} --pretty=format:"%H|||%s|||%ad|||%an" --date=short --no-merges -n ${maxCommits}`;
8771
- let logOutput = '';
8772
- try {
8773
- logOutput = execSync(logCmd, { cwd: repoDir, encoding: 'utf-8', stdio: 'pipe' });
8774
- }
8775
- catch (e) {
8776
- return `❌ git log failed: ${e.message}. Check \`repo_path\` and \`branch\`.`;
8777
- }
8778
- const commits = logOutput.trim().split('\n').filter(Boolean).map(line => {
8779
- const [sha, subject, date, author] = line.split('|||');
8780
- return { sha: (sha ?? '').trim(), subject: (subject ?? '').trim(), date: (date ?? '').trim(), author: (author ?? '').trim() };
8781
- });
8782
- if (commits.length === 0) {
8783
- return `⚠️ No commits found in \`${repoDir}\` on branch \`${branch}\`${since ? ` since ${since}` : ''}.`;
8784
- }
8785
- // Pattern classifiers
8786
- const classifyCommit = (subject) => {
8787
- const s = subject.toLowerCase();
8788
- if (/\b(fix|fixed|fixes|bug|hotfix|patch|revert|resolve|closes? #\d+)\b/.test(s)) {
8789
- const sev = /\b(critical|crash|security|auth|data loss|outage|prod|production)\b/.test(s) ? 'critical' : /\b(major|breaking|regression|hotfix)\b/.test(s) ? 'major' : 'minor';
8790
- return { category: 'fix', outcome: 'success', severity: sev };
8791
- }
8792
- if (/\b(feat|feature|add|added|implement|new|introduce)\b/.test(s))
8793
- return { category: 'feat', outcome: 'success', severity: 'minor' };
8794
- if (/\b(refactor|clean|cleanup|improve|simplify|extract|rename)\b/.test(s))
8795
- return { category: 'refactor', outcome: 'success', severity: 'minor' };
8796
- if (/\b(perf|optimize|speed|cache|latency|memory|performance)\b/.test(s))
8797
- return { category: 'perf', outcome: 'success', severity: 'major' };
8798
- if (/\b(security|cve|auth|csrf|xss|sql|injection|sanitize|escape|encrypt)\b/.test(s))
8799
- return { category: 'security', outcome: 'success', severity: 'critical' };
8800
- if (/\b(deploy|ci|cd|build|docker|k8s|helm|infra|devops)\b/.test(s))
8801
- return { category: 'deploy', outcome: 'success', severity: 'major' };
8802
- if (/\b(test|spec|coverage|assert|mock|unit|integration)\b/.test(s))
8803
- return { category: 'test', outcome: 'success', severity: 'minor' };
8804
- return { category: 'chore', outcome: 'success', severity: 'minor' };
8805
- };
8806
- // Extract domain keywords from commit subject
8807
- const extractDomain = (subject) => {
8808
- const s = subject.toLowerCase();
8809
- const tokens = s.replace(/[^a-z0-9\s\-_]/g, ' ').split(/\s+/).filter(t => t.length > 3 && !['that', 'this', 'with', 'from', 'when', 'into', 'also', 'some', 'were'].includes(t));
8810
- return tokens.slice(0, 3).join('-') || 'general';
8811
- };
8812
- const ts = new Date().toISOString();
8813
- let ingested = 0;
8814
- let skipped = 0;
8815
- const categoryCount = new Map();
8816
- for (const commit of commits) {
8817
- if (!commit.subject) {
8818
- skipped++;
8819
- continue;
8850
+ // Verify it's a git repo
8851
+ try {
8852
+ execSync('git rev-parse --git-dir', { cwd: repoDir, stdio: 'pipe' });
8820
8853
  }
8821
- const { category, outcome, severity } = classifyCommit(commit.subject);
8822
- const domain = extractDomain(commit.subject);
8823
- const topic = `${category}:${domain}`;
8824
- categoryCount.set(category, (categoryCount.get(category) ?? 0) + 1);
8825
- const lessonObj = {
8826
- topic, outcome, severity,
8827
- what_worked: commit.subject.slice(0, 200),
8828
- what_failed: '',
8829
- context: `git:${commit.sha.slice(0, 8)} by ${commit.author} on ${commit.date}`,
8830
- file_paths: [], commands: [`git show ${commit.sha.slice(0, 8)}`],
8831
- tags: ['brain_from_git', category, 'git-history'],
8832
- depends_on: [], recall_count: 0, ts, verified_at: ts,
8833
- confidence: 0.55, // lower confidence for auto-inferred lessons
8834
- audit_trail: [{ ts, action: 'brain_from_git', sha: commit.sha.slice(0, 8) }],
8835
- version: 3,
8836
- };
8837
- // Only store if no existing lesson for this topic (avoid overwriting higher-confidence lessons)
8838
- const existing = await redis.get(`cachly:lesson:best:${topic}`);
8839
- if (!existing) {
8840
- await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(lessonObj));
8841
- await redis.rpush(`cachly:lessons:${topic}`, JSON.stringify(lessonObj));
8854
+ catch {
8855
+ return `❌ Not a git repository: \`${repoDir}\`. Pass \`repo_path\` pointing to a git checkout.`;
8842
8856
  }
8843
- await redis.rpush('cachly:lessons:brain_from_git:all', JSON.stringify({ topic, sha: commit.sha.slice(0, 8), subject: commit.subject.slice(0, 60) }));
8844
- await redis.ltrim('cachly:lessons:brain_from_git:all', -500, -1);
8845
- // Update CKG
8846
- const conceptId = ckgSlug(topic);
8847
- await ckgUpsertNode(redis, conceptId, category, 'git-derived');
8848
- ingested++;
8857
+ // Build git log command
8858
+ const sinceFlag = since ? `--since="${since}"` : '';
8859
+ const logCmd = `git log ${branch} ${sinceFlag} --pretty=format:"%H|||%s|||%ad|||%an" --date=short --no-merges -n ${maxCommits}`;
8860
+ let logOutput = '';
8861
+ try {
8862
+ logOutput = execSync(logCmd, { cwd: repoDir, encoding: 'utf-8', stdio: 'pipe' });
8863
+ }
8864
+ catch (e) {
8865
+ return `❌ git log failed: ${e.message}. Check \`repo_path\` and \`branch\`.`;
8866
+ }
8867
+ const commits = logOutput.trim().split('\n').filter(Boolean).map(line => {
8868
+ const [sha, subject, date, author] = line.split('|||');
8869
+ return { sha: (sha ?? '').trim(), subject: (subject ?? '').trim(), date: (date ?? '').trim(), author: (author ?? '').trim() };
8870
+ });
8871
+ if (commits.length === 0) {
8872
+ return `⚠️ No commits found in \`${repoDir}\` on branch \`${branch}\`${since ? ` since ${since}` : ''}.`;
8873
+ }
8874
+ // Pattern classifiers
8875
+ const classifyCommit = (subject) => {
8876
+ const s = subject.toLowerCase();
8877
+ if (/\b(fix|fixed|fixes|bug|hotfix|patch|revert|resolve|closes? #\d+)\b/.test(s)) {
8878
+ const sev = /\b(critical|crash|security|auth|data loss|outage|prod|production)\b/.test(s) ? 'critical' : /\b(major|breaking|regression|hotfix)\b/.test(s) ? 'major' : 'minor';
8879
+ return { category: 'fix', outcome: 'success', severity: sev };
8880
+ }
8881
+ if (/\b(feat|feature|add|added|implement|new|introduce)\b/.test(s))
8882
+ return { category: 'feat', outcome: 'success', severity: 'minor' };
8883
+ if (/\b(refactor|clean|cleanup|improve|simplify|extract|rename)\b/.test(s))
8884
+ return { category: 'refactor', outcome: 'success', severity: 'minor' };
8885
+ if (/\b(perf|optimize|speed|cache|latency|memory|performance)\b/.test(s))
8886
+ return { category: 'perf', outcome: 'success', severity: 'major' };
8887
+ if (/\b(security|cve|auth|csrf|xss|sql|injection|sanitize|escape|encrypt)\b/.test(s))
8888
+ return { category: 'security', outcome: 'success', severity: 'critical' };
8889
+ if (/\b(deploy|ci|cd|build|docker|k8s|helm|infra|devops)\b/.test(s))
8890
+ return { category: 'deploy', outcome: 'success', severity: 'major' };
8891
+ if (/\b(test|spec|coverage|assert|mock|unit|integration)\b/.test(s))
8892
+ return { category: 'test', outcome: 'success', severity: 'minor' };
8893
+ return { category: 'chore', outcome: 'success', severity: 'minor' };
8894
+ };
8895
+ // Extract domain keywords from commit subject
8896
+ const extractDomain = (subject) => {
8897
+ const s = subject.toLowerCase();
8898
+ const tokens = s.replace(/[^a-z0-9\s\-_]/g, ' ').split(/\s+/).filter(t => t.length > 3 && !['that', 'this', 'with', 'from', 'when', 'into', 'also', 'some', 'were'].includes(t));
8899
+ return tokens.slice(0, 3).join('-') || 'general';
8900
+ };
8901
+ const ts = new Date().toISOString();
8902
+ let ingested = 0;
8903
+ let skipped = 0;
8904
+ const categoryCount = new Map();
8905
+ for (const commit of commits) {
8906
+ if (!commit.subject) {
8907
+ skipped++;
8908
+ continue;
8909
+ }
8910
+ const { category, outcome, severity } = classifyCommit(commit.subject);
8911
+ const domain = extractDomain(commit.subject);
8912
+ const topic = `${category}:${domain}`;
8913
+ categoryCount.set(category, (categoryCount.get(category) ?? 0) + 1);
8914
+ const lessonObj = {
8915
+ topic, outcome, severity,
8916
+ what_worked: commit.subject.slice(0, 200),
8917
+ what_failed: '',
8918
+ context: `git:${commit.sha.slice(0, 8)} by ${commit.author} on ${commit.date}`,
8919
+ file_paths: [], commands: [`git show ${commit.sha.slice(0, 8)}`],
8920
+ tags: ['brain_from_git', category, 'git-history'],
8921
+ depends_on: [], recall_count: 0, ts, verified_at: ts,
8922
+ confidence: 0.55, // lower confidence for auto-inferred lessons
8923
+ audit_trail: [{ ts, action: 'brain_from_git', sha: commit.sha.slice(0, 8) }],
8924
+ version: 3,
8925
+ };
8926
+ // Only store if no existing lesson for this topic (avoid overwriting higher-confidence lessons)
8927
+ const existing = await redis.get(`cachly:lesson:best:${topic}`);
8928
+ if (!existing) {
8929
+ await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(lessonObj));
8930
+ await redis.rpush(`cachly:lessons:${topic}`, JSON.stringify(lessonObj));
8931
+ }
8932
+ await redis.rpush('cachly:lessons:brain_from_git:all', JSON.stringify({ topic, sha: commit.sha.slice(0, 8), subject: commit.subject.slice(0, 60) }));
8933
+ await redis.ltrim('cachly:lessons:brain_from_git:all', -500, -1);
8934
+ // Update CKG
8935
+ const conceptId = ckgSlug(topic);
8936
+ await ckgUpsertNode(redis, conceptId, category, 'git-derived');
8937
+ ingested++;
8938
+ }
8939
+ const categoryBreakdown = [...categoryCount.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ` • **${k}** (${v})`).join('\n');
8940
+ return [
8941
+ `🔁 **brain_from_git: ${repoDir}**`, '',
8942
+ `📂 Branch: \`${branch}\` | Processed: **${commits.length}** commits | Ingested: **${ingested}** lessons | Skipped: ${skipped}`,
8943
+ ``,
8944
+ `**Breakdown by category:**`,
8945
+ categoryBreakdown,
8946
+ ``,
8947
+ `💡 New lessons are stored with confidence 0.55 (auto-inferred).`,
8948
+ `💡 As you confirm them via \`learn_from_attempts\`, confidence rises automatically.`,
8949
+ `🔍 Explore: \`brain_search(query="fix")\` | \`ckg_inspect(concept="deploy")\``,
8950
+ ].join('\n');
8951
+ }
8952
+ finally {
8953
+ _gitSemRelease();
8849
8954
  }
8850
- const categoryBreakdown = [...categoryCount.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ` • **${k}** (${v})`).join('\n');
8851
- return [
8852
- `🔁 **brain_from_git: ${repoDir}**`, '',
8853
- `📂 Branch: \`${branch}\` | Processed: **${commits.length}** commits | Ingested: **${ingested}** lessons | Skipped: ${skipped}`,
8854
- ``,
8855
- `**Breakdown by category:**`,
8856
- categoryBreakdown,
8857
- ``,
8858
- `💡 New lessons are stored with confidence 0.55 (auto-inferred).`,
8859
- `💡 As you confirm them via \`learn_from_attempts\`, confidence rises automatically.`,
8860
- `🔍 Explore: \`brain_search(query="fix")\` | \`ckg_inspect(concept="deploy")\``,
8861
- ].join('\n');
8862
8955
  }
8863
8956
  // ── brain_predict_failures ─────────────────────────────────────────────────
8864
8957
  case 'brain_predict_failures': {
@@ -8999,6 +9092,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
8999
9092
  let _autoSessionInstanceId = null;
9000
9093
  let _autoSessionStarted = false;
9001
9094
  let _autoSessionToolCount = 0;
9095
+ // ── brain_from_git concurrency limiter ────────────────────────────────────────
9096
+ // Spawning many `git log` subprocesses in parallel can fork-bomb the host.
9097
+ // This simple semaphore caps concurrent brain_from_git calls to 10 per process.
9098
+ let _gitSemCount = 0;
9099
+ const _GIT_SEM_MAX = 10;
9100
+ const _gitSemQueue = [];
9101
+ function _gitSemAcquire() {
9102
+ if (_gitSemCount < _GIT_SEM_MAX) {
9103
+ _gitSemCount++;
9104
+ return Promise.resolve();
9105
+ }
9106
+ return new Promise(resolve => _gitSemQueue.push(resolve));
9107
+ }
9108
+ function _gitSemRelease() {
9109
+ const next = _gitSemQueue.shift();
9110
+ if (next) {
9111
+ next();
9112
+ }
9113
+ else {
9114
+ _gitSemCount--;
9115
+ }
9116
+ }
9002
9117
  async function autoStartSession(instanceId) {
9003
9118
  if (_autoSessionStarted)
9004
9119
  return;
@@ -9235,6 +9350,47 @@ if (process.argv[2] === 'init') {
9235
9350
  const result = await writeClaudeMd(projectDir, instanceId);
9236
9351
  const action = result === 'updated' ? '✅ Updated' : result === 'appended' ? '✅ Appended to' : '✅ Written';
9237
9352
  console.log(`${action}: CLAUDE.md`);
9353
+ // ── CLS Phase 4: Auto-install git post-commit hook ─────────────────────────
9354
+ try {
9355
+ const { existsSync } = await import('node:fs');
9356
+ const gitHookDir = resolve(projectDir, '.git', 'hooks');
9357
+ const hookPath = resolve(gitHookDir, 'post-commit');
9358
+ if (existsSync(resolve(projectDir, '.git'))) {
9359
+ await mkdir(gitHookDir, { recursive: true });
9360
+ const hookScript = [
9361
+ `#!/bin/sh`,
9362
+ `# cachly CLS — Continuous Learning Stream (installed by cachly init)`,
9363
+ `# Runs silently on every commit to keep your brain up to date.`,
9364
+ `CACHLY_INSTANCE="${instanceId}"`,
9365
+ `SHA=$(git rev-parse HEAD 2>/dev/null || echo "")`,
9366
+ `MSG=$(git log -1 --pretty=%B 2>/dev/null | head -1 | tr '"' "'" | cut -c1-200)`,
9367
+ `FILES=$(git diff-tree --no-commit-id -r --name-only HEAD 2>/dev/null | tr '\\n' ',' | sed 's/,$//')`,
9368
+ `node -e "try{require('child_process').execSync('npx @cachly-dev/mcp-server@latest cls-ingest \\''+ JSON.stringify({instance_id:'$CACHLY_INSTANCE',source:'git_commit',payload:{message:'$MSG',sha:'$SHA',files:'$FILES'.split(',').filter(Boolean)}})+'\\'' ,{stdio:'ignore',timeout:5000})}catch(e){}" 2>/dev/null &`,
9369
+ `exit 0`,
9370
+ ].join('\n');
9371
+ let existing = '';
9372
+ try {
9373
+ const { readFile } = await import('node:fs/promises');
9374
+ existing = await readFile(hookPath, 'utf-8');
9375
+ }
9376
+ catch { /* no existing hook */ }
9377
+ if (existing && !existing.includes('cachly CLS')) {
9378
+ // Append to existing hook
9379
+ await writeFile(hookPath, existing.trimEnd() + '\n\n' + hookScript + '\n', 'utf-8');
9380
+ console.log(`✅ Appended: .git/hooks/post-commit (CLS hook)`);
9381
+ }
9382
+ else if (!existing) {
9383
+ await writeFile(hookPath, hookScript + '\n', 'utf-8');
9384
+ const { chmod } = await import('node:fs/promises');
9385
+ await chmod(hookPath, 0o755);
9386
+ console.log(`✅ Written: .git/hooks/post-commit (CLS hook)`);
9387
+ }
9388
+ else {
9389
+ console.log(`✓ CLS hook already present in .git/hooks/post-commit`);
9390
+ }
9391
+ }
9392
+ }
9393
+ catch { /* non-critical — git hook is a best-effort feature */ }
9238
9394
  console.log(`\n🧠 Cachly AI Brain configured for ${editor === 'claude' ? 'Claude Code' : editor}!`);
9239
9395
  console.log(` Restart your editor — the \`cachly\` MCP tools will appear.\n`);
9240
9396
  process.exit(0);
@@ -9562,6 +9718,43 @@ if (process.argv[2] === 'setup') {
9562
9718
  catch { /* fire and forget */ }
9563
9719
  }
9564
9720
  }
9721
+ // ── CLS Phase 4: Auto-install git post-commit hook in setup flow ─────────
9722
+ try {
9723
+ const { existsSync } = await import('node:fs');
9724
+ const { writeFile: wf2, mkdir: mk2, chmod: ch2, readFile: rf2 } = await import('node:fs/promises');
9725
+ const { resolve: res2 } = await import('node:path');
9726
+ const setupProjectDir = process.cwd();
9727
+ const gitHookDir2 = res2(setupProjectDir, '.git', 'hooks');
9728
+ const hookPath2 = res2(gitHookDir2, 'post-commit');
9729
+ if (existsSync(res2(setupProjectDir, '.git'))) {
9730
+ await mk2(gitHookDir2, { recursive: true });
9731
+ const hs2 = [
9732
+ `#!/bin/sh`,
9733
+ `# cachly CLS — Continuous Learning Stream (installed by cachly setup)`,
9734
+ `CACHLY_INSTANCE="${instance.id}"`,
9735
+ `SHA=$(git rev-parse HEAD 2>/dev/null || echo "")`,
9736
+ `MSG=$(git log -1 --pretty=%B 2>/dev/null | head -1 | tr '"' "'" | cut -c1-200)`,
9737
+ `FILES=$(git diff-tree --no-commit-id -r --name-only HEAD 2>/dev/null | tr '\\n' ',' | sed 's/,$//')`,
9738
+ `node -e "try{require('child_process').execSync('npx @cachly-dev/mcp-server@latest cls-ingest \\''+ JSON.stringify({instance_id:'$CACHLY_INSTANCE',source:'git_commit',payload:{message:'$MSG',sha:'$SHA',files:'$FILES'.split(',').filter(Boolean)}})+'\\'' ,{stdio:'ignore',timeout:5000})}catch(e){}" 2>/dev/null &`,
9739
+ `exit 0`,
9740
+ ].join('\n');
9741
+ let ex2 = '';
9742
+ try {
9743
+ ex2 = await rf2(hookPath2, 'utf-8');
9744
+ }
9745
+ catch { /* no existing */ }
9746
+ if (!ex2) {
9747
+ await wf2(hookPath2, hs2 + '\n', 'utf-8');
9748
+ await ch2(hookPath2, 0o755);
9749
+ console.log(`\n✅ CLS git hook installed — your brain will learn from every commit.`);
9750
+ }
9751
+ else if (!ex2.includes('cachly CLS')) {
9752
+ await wf2(hookPath2, ex2.trimEnd() + '\n\n' + hs2 + '\n', 'utf-8');
9753
+ console.log(`\n✅ CLS git hook appended to existing post-commit.`);
9754
+ }
9755
+ }
9756
+ }
9757
+ catch { /* non-critical */ }
9565
9758
  rl.close();
9566
9759
  process.exit(0);
9567
9760
  }