@cachly-dev/mcp-server 0.5.7 → 0.5.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cachly-dev/mcp-server",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "MCP server – manage cachly.dev instances from any AI assistant (Copilot, Claude, Cursor, Windsurf)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.ts CHANGED
@@ -1343,6 +1343,11 @@ const TOOLS = [
1343
1343
  items: { type: 'string' },
1344
1344
  description: 'Topic tags for filtering (e.g. ["bash", "deploy", "env"])',
1345
1345
  },
1346
+ depends_on: {
1347
+ type: 'array',
1348
+ items: { type: 'string' },
1349
+ description: 'Prerequisites this lesson depends on (e.g. ["node:>=20", "docker:running", "wireguard:active"]). When a dependency is marked stale, all dependent lessons get needs_review.',
1350
+ },
1346
1351
  },
1347
1352
  required: ['instance_id', 'topic', 'outcome', 'what_worked'],
1348
1353
  },
@@ -1572,6 +1577,22 @@ const TOOLS = [
1572
1577
  required: ['instance_id'],
1573
1578
  },
1574
1579
  },
1580
+ {
1581
+ name: 'team_synthesize',
1582
+ description:
1583
+ 'Team Brain Synthesis — merge multiple contributors\' lessons on the same topic into one canonical version. ' +
1584
+ 'When 2+ developers store lessons for the same topic with different details, this proposes the best merged version. ' +
1585
+ 'Shows: all contributions by author, what worked (consensus), what failed (union), canonical lesson to store. ' +
1586
+ 'Use this when onboarding new team members or before documenting a process.',
1587
+ inputSchema: {
1588
+ type: 'object',
1589
+ properties: {
1590
+ instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
1591
+ topic: { type: 'string', description: 'Topic slug to synthesize (e.g. "deploy:api")' },
1592
+ },
1593
+ required: ['instance_id', 'topic'],
1594
+ },
1595
+ },
1575
1596
  {
1576
1597
  name: 'brain_doctor',
1577
1598
  description:
@@ -1653,6 +1674,100 @@ const TOOLS = [
1653
1674
  required: ['instance_id', 'framework'],
1654
1675
  },
1655
1676
  },
1677
+ // ── Brain Archaeology + Causal Chain ────────────────────────────────────
1678
+ {
1679
+ name: 'recall_at',
1680
+ description:
1681
+ 'Brain Archaeology — see what a lesson looked like at a specific point in time. ' +
1682
+ '"What did we know about deployments 3 months ago?" ' +
1683
+ 'Returns the history of a topic filtered to entries before the given date. ' +
1684
+ 'Shows how the lesson evolved: failure → partial → success. ' +
1685
+ 'Also useful to understand WHY old code decisions were made.',
1686
+ inputSchema: {
1687
+ type: 'object',
1688
+ properties: {
1689
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1690
+ topic: { type: 'string', description: 'Topic slug to look up, e.g. "deploy:api"' },
1691
+ date: { type: 'string', description: 'ISO date string (e.g. "2026-01-15") — returns entries stored BEFORE this date' },
1692
+ },
1693
+ required: ['instance_id', 'topic', 'date'],
1694
+ },
1695
+ },
1696
+ {
1697
+ name: 'trace_dependency',
1698
+ description:
1699
+ 'Causal Chain — find all lessons that depend on a given prerequisite. ' +
1700
+ '"What lessons are affected if node version changes?" ' +
1701
+ 'When a dependency changes (new version, different provider, new OS), call this to see which lessons need review. ' +
1702
+ 'Lessons store dependencies via the depends_on field in learn_from_attempts.',
1703
+ inputSchema: {
1704
+ type: 'object',
1705
+ properties: {
1706
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1707
+ dependency: { type: 'string', description: 'Dependency to trace (e.g. "node:>=20", "docker:running", "wireguard:active")' },
1708
+ mark_review: { type: 'boolean', description: 'If true, marks all dependent lessons as needs_review (default: false)' },
1709
+ },
1710
+ required: ['instance_id', 'dependency'],
1711
+ },
1712
+ },
1713
+ // ── Team / Org Management ────────────────────────────────────────────────
1714
+ {
1715
+ name: 'list_orgs',
1716
+ description:
1717
+ 'List your Cachly organizations (team/org plans). ' +
1718
+ 'Returns each org with plan, seat count, and member info. ' +
1719
+ 'Org plans (Team €99, Business €299, Enterprise custom) are billed separately from cache tiers.',
1720
+ inputSchema: {
1721
+ type: 'object',
1722
+ properties: {},
1723
+ required: [],
1724
+ },
1725
+ },
1726
+ {
1727
+ name: 'create_org',
1728
+ description:
1729
+ 'Create a new Cachly organization for team collaboration. ' +
1730
+ 'After creation, invite team members with invite_member and upgrade the plan via the billing portal. ' +
1731
+ 'Org plans: Team (€99/mo, 10 seats), Business (€299/mo, 50 seats), Enterprise (custom).',
1732
+ inputSchema: {
1733
+ type: 'object',
1734
+ properties: {
1735
+ name: { type: 'string', description: 'Organization display name (e.g. "Acme Engineering")' },
1736
+ slug: { type: 'string', description: 'URL-safe slug (e.g. "acme-eng"). Auto-generated from name if omitted.' },
1737
+ },
1738
+ required: ['name'],
1739
+ },
1740
+ },
1741
+ {
1742
+ name: 'invite_member',
1743
+ description:
1744
+ 'Invite a team member to a Cachly organization by email. ' +
1745
+ 'They will receive an invite email and can join via the dashboard. ' +
1746
+ 'Roles: owner (full access), admin (manage members + instances), member (read + cache ops).',
1747
+ inputSchema: {
1748
+ type: 'object',
1749
+ properties: {
1750
+ org_id: { type: 'string', description: 'UUID of the organization' },
1751
+ email: { type: 'string', description: 'Email address to invite' },
1752
+ role: { type: 'string', enum: ['admin', 'member'], description: 'Role for the invited member (default: member)' },
1753
+ },
1754
+ required: ['org_id', 'email'],
1755
+ },
1756
+ },
1757
+ {
1758
+ name: 'get_org_plan',
1759
+ description:
1760
+ 'Get the current org plan, seat usage, and billing info for an organization. ' +
1761
+ 'Shows: plan name, price, seats used/max, next billing date. ' +
1762
+ 'To upgrade: use the billing portal URL returned by this tool.',
1763
+ inputSchema: {
1764
+ type: 'object',
1765
+ properties: {
1766
+ org_id: { type: 'string', description: 'UUID of the organization' },
1767
+ },
1768
+ required: ['org_id'],
1769
+ },
1770
+ },
1656
1771
  // ── Legacy / Setup ────────────────────────────────────────────────────────
1657
1772
  {
1658
1773
  name: 'setup_ai_memory',
@@ -1829,6 +1944,69 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
1829
1944
  return `✅ Instance \`${instance_id}\` has been deleted and all data removed.`;
1830
1945
  }
1831
1946
 
1947
+ // ── Org / Team management ────────────────────────────────────────────
1948
+ case 'list_orgs': {
1949
+ const res = await apiFetch<{ orgs: Array<{ id: string; name: string; slug: string; plan: string; max_members: number; member_count?: number }> }>('/api/v1/orgs');
1950
+ const orgs = res.orgs ?? [];
1951
+ if (orgs.length === 0) return `📭 No organizations yet.\n\nCreate one with \`create_org(name="My Team")\`.\nOrg plans: Team €99/mo (10 seats), Business €299/mo (50 seats), Enterprise custom.`;
1952
+ return [
1953
+ `🏢 **Your organizations (${orgs.length})**\n`,
1954
+ ...orgs.map(o => `• **${o.name}** (\`${o.slug}\`) — plan: ${o.plan} · seats: ${o.member_count ?? '?'}/${o.max_members}\n ID: \`${o.id}\``),
1955
+ `\n_Manage: \`get_org_plan\`, \`invite_member\`, dashboard → /team_`,
1956
+ ].join('\n');
1957
+ }
1958
+
1959
+ case 'create_org': {
1960
+ const { name: orgName, slug } = args as { name: string; slug?: string };
1961
+ const body: Record<string, string> = { name: orgName };
1962
+ if (slug) body.slug = slug;
1963
+ const res = await apiFetch<{ id: string; name: string; slug: string; plan: string }>('/api/v1/orgs', {
1964
+ method: 'POST',
1965
+ body: JSON.stringify(body),
1966
+ });
1967
+ return [
1968
+ `✅ **Organization created:** ${res.name}`,
1969
+ ` ID: \`${res.id}\` · Slug: \`${res.slug}\` · Plan: ${res.plan}`,
1970
+ ``,
1971
+ `**Next steps:**`,
1972
+ `1. Invite team members: \`invite_member(org_id="${res.id}", email="dev@example.com")\``,
1973
+ `2. Upgrade plan: open billing portal via dashboard → /team`,
1974
+ ` Team: €99/mo (10 seats) · Business: €299/mo (50 seats)`,
1975
+ ].join('\n');
1976
+ }
1977
+
1978
+ case 'invite_member': {
1979
+ const { org_id, email, role = 'member' } = args as { org_id: string; email: string; role?: string };
1980
+ await apiFetch(`/api/v1/orgs/${org_id}/members`, {
1981
+ method: 'POST',
1982
+ body: JSON.stringify({ email, role }),
1983
+ });
1984
+ return `✅ Invite sent to **${email}** as \`${role}\` in org \`${org_id}\`.\n\nThey will receive an email to join the organization.`;
1985
+ }
1986
+
1987
+ case 'get_org_plan': {
1988
+ const { org_id } = args as { org_id: string };
1989
+ const org = await apiFetch<{
1990
+ id: string; name: string; plan: string; max_members: number;
1991
+ members: Array<{ role: string; invite_email: string; accepted_at?: string }>;
1992
+ stripe_customer_id?: string;
1993
+ }>(`/api/v1/orgs/${org_id}`);
1994
+ const accepted = (org.members ?? []).filter(m => m.accepted_at).length;
1995
+ const pending = (org.members ?? []).filter(m => !m.accepted_at).length;
1996
+ const planPrice: Record<string, string> = { free: '€0', team: '€99/mo', business: '€299/mo', enterprise: 'custom' };
1997
+ return [
1998
+ `🏢 **${org.name}** — Plan: **${org.plan}** (${planPrice[org.plan] ?? org.plan})`,
1999
+ ` Seats: ${accepted} active + ${pending} pending / ${org.max_members} max`,
2000
+ ``,
2001
+ `**Members:**`,
2002
+ ...(org.members ?? []).map(m => ` • ${m.invite_email} (${m.role})${m.accepted_at ? '' : ' — pending'}`),
2003
+ ``,
2004
+ org.stripe_customer_id
2005
+ ? `💳 Billing: managed via Stripe. Upgrade/cancel: dashboard → /billing`
2006
+ : `💳 No payment method yet. Upgrade: dashboard → /billing → Team Plans`,
2007
+ ].join('\n');
2008
+ }
2009
+
1832
2010
  // ── Live cache operations ────────────────────────────────────────────
1833
2011
  case 'cache_get': {
1834
2012
  const { instance_id, key } = args as { instance_id: string; key: string };
@@ -2556,6 +2734,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2556
2734
  file_paths = [],
2557
2735
  commands = [],
2558
2736
  tags = [],
2737
+ depends_on = [],
2559
2738
  } = args as {
2560
2739
  instance_id: string;
2561
2740
  topic: string;
@@ -2567,6 +2746,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2567
2746
  file_paths?: string[];
2568
2747
  commands?: string[];
2569
2748
  tags?: string[];
2749
+ depends_on?: string[];
2570
2750
  };
2571
2751
 
2572
2752
  const redis = await getConnection(instance_id);
@@ -2624,6 +2804,47 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2624
2804
  auditTrail = [{ ts, action: 'created' }];
2625
2805
  }
2626
2806
 
2807
+ // ── "I Was Wrong" Protocol — failure attribution ───────────────────────
2808
+ const iWasWrongWarning: string[] = [];
2809
+ if (outcome === 'failure') {
2810
+ // Search for related success lessons that might have prevented this failure
2811
+ const scanKeys: string[] = [];
2812
+ const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
2813
+ await new Promise<void>((res, rej) => {
2814
+ scanStream.on('data', (b: string[]) => scanKeys.push(...b));
2815
+ scanStream.on('end', res);
2816
+ scanStream.on('error', rej);
2817
+ });
2818
+ const topicWords = topic.split(/[:\-_]/).filter(w => w.length > 2);
2819
+ for (const k of scanKeys.slice(0, 50)) {
2820
+ const raw = await redis.get(k);
2821
+ if (!raw) continue;
2822
+ try {
2823
+ const l = JSON.parse(raw) as { outcome?: string; topic?: string; severity?: string };
2824
+ if (l.outcome !== 'success') continue;
2825
+ const lWords = (l.topic ?? '').split(/[:\-_]/).filter(w => w.length > 2);
2826
+ const overlap = topicWords.filter(w => lWords.includes(w)).length;
2827
+ if (overlap >= 1 && l.topic !== topic) {
2828
+ iWasWrongWarning.push(
2829
+ `⚠️ **"I Was Wrong"**: lesson \`${l.topic}\` (success, ${l.severity ?? 'major'}) might have prevented this failure.`,
2830
+ ` → Use \`recall_best_solution(topic="${l.topic}")\` before next attempt.`,
2831
+ ` → To mark it critical: \`learn_from_attempts(topic="${l.topic}", ..., severity="critical")\``,
2832
+ );
2833
+ break; // only show the most relevant match
2834
+ }
2835
+ } catch { /* skip */ }
2836
+ }
2837
+ }
2838
+
2839
+ // ── Register dependency index for causal chain ─────────────────────────
2840
+ for (const dep of depends_on) {
2841
+ const depKey = `cachly:dep:${dep}`;
2842
+ const existing = await redis.get(depKey);
2843
+ const depTopics: string[] = existing ? JSON.parse(existing) : [];
2844
+ if (!depTopics.includes(topic)) depTopics.push(topic);
2845
+ await redis.set(depKey, JSON.stringify(depTopics));
2846
+ }
2847
+
2627
2848
  const lessonObj = {
2628
2849
  topic,
2629
2850
  outcome,
@@ -2634,6 +2855,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2634
2855
  file_paths,
2635
2856
  commands,
2636
2857
  tags,
2858
+ depends_on,
2637
2859
  recall_count: recallCount,
2638
2860
  ts,
2639
2861
  verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
@@ -2678,7 +2900,11 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2678
2900
  isUpdate
2679
2901
  ? `♻️ Updated (recall count: ${recallCount} · audit entries: ${auditTrail.length})`
2680
2902
  : `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
2903
+ depends_on.length > 0
2904
+ ? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
2905
+ : '',
2681
2906
  ...templateWarnings,
2907
+ ...iWasWrongWarning,
2682
2908
  ].filter(l => l !== '').join('\n');
2683
2909
  }
2684
2910
 
@@ -3594,6 +3820,71 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3594
3820
  return lines.join('\n');
3595
3821
  }
3596
3822
 
3823
+ // ── team_synthesize — Team Brain Synthesis ────────────────────────────────
3824
+ case 'team_synthesize': {
3825
+ const { instance_id, topic } = args as { instance_id: string; topic: string };
3826
+ const redis = await getConnection(instance_id);
3827
+
3828
+ // Load history list for this topic (all authors' contributions)
3829
+ const listKey = `cachly:lessons:${topic}`;
3830
+ const all = await redis.lrange(listKey, 0, -1);
3831
+ if (all.length < 2) {
3832
+ return `📭 Need at least 2 entries for topic \`${topic}\` to synthesize.\n\nCurrently: ${all.length} entr${all.length === 1 ? 'y' : 'ies'}.\n\nHave team members store lessons via \`learn_from_attempts(topic="${topic}", ...)\`.`;
3833
+ }
3834
+
3835
+ type Entry = { outcome: string; what_worked: string; what_failed?: string; author?: string; ts: string; severity?: string };
3836
+ const entries: Entry[] = all.map(r => { try { return JSON.parse(r) as Entry; } catch { return null; } }).filter((e): e is Entry => e !== null);
3837
+
3838
+ // Group by outcome
3839
+ const successes = entries.filter(e => e.outcome === 'success');
3840
+ const failures = entries.filter(e => e.outcome === 'failure');
3841
+ const partials = entries.filter(e => e.outcome === 'partial');
3842
+
3843
+ const authors = [...new Set(entries.map(e => e.author).filter(Boolean))];
3844
+ const hasMultiAuthor = authors.length > 1;
3845
+
3846
+ // Build canonical merged version
3847
+ // what_worked: pick the most recent success, or longest for most detail
3848
+ const bestSuccess = successes.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
3849
+ const whatWorkedCandidates = successes.map(e => e.what_worked).filter(w => w && w.length > 10);
3850
+ const canonicalWorked = whatWorkedCandidates.sort((a, b) => b.length - a.length)[0] ?? bestSuccess?.what_worked ?? '';
3851
+
3852
+ // what_failed: union of all unique failure reasons
3853
+ const allFailed = [...new Set(
3854
+ [...failures, ...partials].map(e => e.what_failed).filter((w): w is string => !!w && w.length > 5)
3855
+ )];
3856
+
3857
+ const severities = entries.map(e => e.severity).filter(Boolean);
3858
+ const canonicalSeverity = severities.includes('critical') ? 'critical' : severities.includes('major') ? 'major' : 'minor';
3859
+
3860
+ const lines = [
3861
+ `🧬 **Team Brain Synthesis: \`${topic}\`**`,
3862
+ `_${entries.length} entries from ${authors.length} author${authors.length === 1 ? '' : 's'}${hasMultiAuthor ? ` (${authors.join(', ')})` : ''} · ${successes.length} success · ${failures.length} failure · ${partials.length} partial_`,
3863
+ '',
3864
+ `**Canonical "what worked":**`,
3865
+ `> ${canonicalWorked}`,
3866
+ '',
3867
+ allFailed.length > 0 ? `**Avoid (combined failures):**` : '',
3868
+ ...allFailed.map(f => `> ❌ ${f}`),
3869
+ allFailed.length > 0 ? '' : '',
3870
+ `**Suggested canonical lesson:**`,
3871
+ '```',
3872
+ `learn_from_attempts(`,
3873
+ ` topic = "${topic}",`,
3874
+ ` outcome = "success",`,
3875
+ ` what_worked = "${canonicalWorked.replace(/"/g, "'")}",`,
3876
+ allFailed.length > 0 ? ` what_failed = "${allFailed[0].replace(/"/g, "'")}",` : '',
3877
+ ` severity = "${canonicalSeverity}",`,
3878
+ `)`,
3879
+ '```',
3880
+ '',
3881
+ hasMultiAuthor
3882
+ ? `💡 _${authors.length} team members contributed to this synthesis. Store the canonical version to replace individual entries._`
3883
+ : `💡 _Single author — more value when multiple team members contribute to the same topic._`,
3884
+ ].filter(l => l !== undefined).join('\n');
3885
+ return lines;
3886
+ }
3887
+
3597
3888
  // ── brain_doctor ──────────────────────────────────────────────────────────
3598
3889
  case 'brain_doctor': {
3599
3890
  const { instance_id } = args as { instance_id: string };
@@ -3717,6 +4008,77 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3717
4008
  return lines.join('\n');
3718
4009
  }
3719
4010
 
4011
+ // ── recall_at — Brain Archaeology ────────────────────────────────────────
4012
+ case 'recall_at': {
4013
+ const { instance_id, topic, date } = args as { instance_id: string; topic: string; date: string };
4014
+ const redis = await getConnection(instance_id);
4015
+ const cutoff = new Date(date).getTime();
4016
+ if (isNaN(cutoff)) return `❌ Invalid date "${date}". Use ISO format: "2026-01-15"`;
4017
+
4018
+ const listKey = `cachly:lessons:${topic}`;
4019
+ const all = await redis.lrange(listKey, 0, -1);
4020
+ if (all.length === 0) return `📭 No history found for \`${topic}\`. Lessons are stored via \`learn_from_attempts\`.`;
4021
+
4022
+ const before = all
4023
+ .map(raw => { try { return JSON.parse(raw) as { ts: string; outcome?: string; what_worked?: string; what_failed?: string }; } catch { return null; } })
4024
+ .filter((l): l is NonNullable<typeof l> => l !== null && new Date(l.ts).getTime() <= cutoff)
4025
+ .sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
4026
+
4027
+ if (before.length === 0) return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${new Date(JSON.parse(all[0]).ts).toLocaleDateString('de-DE')}.`;
4028
+
4029
+ const lines = [
4030
+ `🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
4031
+ `_${before.length} of ${all.length} total entries shown_`,
4032
+ '',
4033
+ ];
4034
+ for (const l of before.slice(-10)) {
4035
+ const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
4036
+ const d = new Date(l.ts).toLocaleDateString('de-DE');
4037
+ lines.push(`**${d}** ${emoji} ${l.outcome}`);
4038
+ if (l.what_worked) lines.push(` → ${l.what_worked.slice(0, 100)}`);
4039
+ lines.push('');
4040
+ }
4041
+ lines.push(`_Full evolution: ${all.map(r => { try { const l = JSON.parse(r) as { outcome?: string }; return l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌'; } catch { return '?'; } }).join(' → ')}_`);
4042
+ return lines.join('\n');
4043
+ }
4044
+
4045
+ // ── trace_dependency — Causal Chain ──────────────────────────────────────
4046
+ case 'trace_dependency': {
4047
+ const { instance_id, dependency, mark_review = false } = args as { instance_id: string; dependency: string; mark_review?: boolean };
4048
+ const redis = await getConnection(instance_id);
4049
+
4050
+ const depKey = `cachly:dep:${dependency}`;
4051
+ const raw = await redis.get(depKey);
4052
+ if (!raw) return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
4053
+
4054
+ const topics: string[] = JSON.parse(raw);
4055
+ const lines = [
4056
+ `🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
4057
+ '',
4058
+ ];
4059
+
4060
+ for (const t of topics) {
4061
+ const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
4062
+ if (!lessonRaw) { lines.push(` • \`${t}\` _(lesson deleted)_`); continue; }
4063
+ const lesson = JSON.parse(lessonRaw) as { outcome?: string; severity?: string; needs_review?: boolean };
4064
+ const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
4065
+ const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
4066
+ lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
4067
+
4068
+ if (mark_review) {
4069
+ const updated = { ...lesson, needs_review: true };
4070
+ await redis.set(`cachly:lesson:best:${t}`, JSON.stringify(updated));
4071
+ }
4072
+ }
4073
+
4074
+ if (mark_review) {
4075
+ lines.push('', `🔍 All ${topics.length} lessons marked as **needs_review** — verify they still work with the changed dependency.`);
4076
+ } else {
4077
+ lines.push('', `_Run with \`mark_review: true\` to flag all dependent lessons for re-verification._`);
4078
+ }
4079
+ return lines.join('\n');
4080
+ }
4081
+
3720
4082
  // ── global_learn ──────────────────────────────────────────────────────────
3721
4083
  case 'global_learn': {
3722
4084
  const { instance_id, topic, lesson, severity = 'minor', tags = [] } = args as {